diff --git a/README.md b/README.md index 74ddb491..0b2d9f9e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Official CLI for [Depot](https://depot.dev) - you can use the CLI to build Docke - [`depot login`](#depot-login) - [`depot logout`](#depot-logout) - [`depot org`](#depot-org) + - [`depot org credits`](#depot-org-credits) - [`depot org list`](#depot-org-list) - [`depot org show`](#depot-org-show) - [`depot org switch`](#depot-org-switch) @@ -749,6 +750,51 @@ depot logout Manage Depot organizations. The `org` command consists of subcommands for each operation. +#### `depot org credits` + +Show the organization's remaining posted Stripe invoice credits. This is the same +balance shown on the billing page; unbilled usage and upcoming invoices are not +deducted, and free-trial allowances and included plan minutes are excluded. + +Use an organization API token or a read-only organization token through +`DEPOT_TOKEN`, `--token`, or the CLI configuration. A user token from `depot login` +is not supported by this endpoint. The token determines the organization, so +`depot org switch` does not change which balance is read and there is no `--org` +flag. The command never prompts for login. + +```shell +# With DEPOT_TOKEN set to an organization token: +depot org credits +depot org credits --output json +``` + +Default output uses the currency's decimal places without rounding the balance: + +```text +Remaining credits: USD 125.00 +Observed at: 2026-10-27T12:00:00Z +``` + +JSON output preserves the API's exact minor-unit amount as a string, including +`"0"` when credit is exhausted: + +```json +{ + "remainingCreditMinor": "12500", + "currency": "USD", + "observedAt": "2026-10-27T12:00:00Z" +} +``` + +For USD, `12500` means $125.00. The timestamp records when the provider balance +was read, not when usage was last processed. + +Each invocation reads the API once. Reads are limited to one per organization +per minute across all tokens and ten per ten seconds across the API fleet. +Excess polls exit with an error containing the server's retry delay. The command +does not retry automatically or cache balances. Poll hourly for most credit +alerts, and handle failed commands separately from a successful zero balance. + #### `depot org list` Display an interactive listing of organizations that you can access. The command shows organization IDs and names in a table format. diff --git a/go.mod b/go.mod index 417680d5..6709bf14 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/depot/cli go 1.26.8 require ( - buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1 - buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1 + buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1 + buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1 charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.0 @@ -242,7 +242,7 @@ require ( golang.org/x/crypto v0.57.0 golang.org/x/mod v0.41.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/text v0.42.0 golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.49.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect diff --git a/go.sum b/go.sum index f25ff632..e65965b9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1 h1:euMjmAIWUpU2cDOTtQ+7TEExRs8J9Fw340KgRcovbnQ= -buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:ocyCo/PaEMnHD7rXfviQM0YvUEaOy0k3494BeBWP3Ak= -buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1 h1:w7R+EBUQqZQ8u73PwJ8XPWEz1TjW82bvCoxokx6Yk2k= -buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g= +buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1 h1:3BKAM0r63Pho+G/pv3ee2QlecyOfR8qjP+J6aap5fgM= +buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1/go.mod h1:wQpUwo9QREPMGVrewsVebSjY7+hVeeCM0tzuUbr8lWs= +buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1 h1:PBhFKLoMH99qsKwZ3IHvzFqJ2w/EEtIYhjiSNxq86HM= +buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= diff --git a/pkg/api/rpc.go b/pkg/api/rpc.go index 0a981626..ef050f91 100644 --- a/pkg/api/rpc.go +++ b/pkg/api/rpc.go @@ -39,6 +39,10 @@ func NewOrganizationsClient() corev1connect.OrganizationServiceClient { return corev1connect.NewOrganizationServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent()) } +func NewCreditClient() corev1connect.CreditServiceClient { + return corev1connect.NewCreditServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent()) +} + func NewSDKProjectsClient() corev1connect.ProjectServiceClient { return corev1connect.NewProjectServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent()) } diff --git a/pkg/cmd/org/credits.go b/pkg/cmd/org/credits.go new file mode 100644 index 00000000..d002e5bc --- /dev/null +++ b/pkg/cmd/org/credits.go @@ -0,0 +1,103 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1" + "connectrpc.com/connect" + "github.com/depot/cli/pkg/api" + "github.com/depot/cli/pkg/helpers" + "github.com/spf13/cobra" + "golang.org/x/text/currency" + "google.golang.org/protobuf/encoding/protojson" +) + +func NewCmdCredits() *cobra.Command { + var token, output string + cmd := &cobra.Command{ + Use: "credits", + Short: "Show remaining organization invoice credits", + Long: `Read the organization's posted Stripe invoice-credit balance. +Unbilled usage is not deducted; free-trial and included plan allowances are excluded. + +Requires an organization API token or a read-only organization token, supplied +with --token, DEPOT_TOKEN, or the CLI configuration. The token determines the +organization; the current organization selected by org switch is not used. +This command does not prompt for login. + +Reads are limited to once per organization per minute and share an API-wide +polling limit. Poll hourly for most monitoring needs. Errors are returned +without retrying or reporting a zero balance.`, + Example: ` # Show remaining credits using an organization token in DEPOT_TOKEN + depot org credits + + # Read the exact API response for a monitoring script + depot org credits --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if output != "" && output != "json" { + return fmt.Errorf("--output must be json") + } + resolvedToken := helpers.ResolveStaticOrgAuth(token) + if resolvedToken == "" { + return fmt.Errorf("missing organization API token: set DEPOT_TOKEN or pass --token (read-only organization tokens are supported)") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + resp, err := api.NewCreditClient().GetCreditBalance(ctx, + api.WithAuthentication(connect.NewRequest(&corev1.GetCreditBalanceRequest{}), resolvedToken)) + if err != nil { + var rpcErr *connect.Error + if errors.As(err, &rpcErr) && rpcErr.Code() == connect.CodeResourceExhausted { + if seconds, parseErr := strconv.Atoi(rpcErr.Meta().Get("Retry-After")); parseErr == nil && seconds > 0 { + return fmt.Errorf("credit balance polling limit exceeded; retry after %d seconds: %w", seconds, err) + } + } + return fmt.Errorf("failed to read credit balance: %w", err) + } + + balance := resp.Msg + if balance.RemainingCreditMinor == nil || balance.GetRemainingCreditMinor() < 0 { + return fmt.Errorf("invalid credit balance response: missing or negative amount") + } + unit, err := currency.ParseISO(balance.Currency) + if err != nil || unit == currency.XXX { + return fmt.Errorf("invalid credit balance response: unknown currency") + } + if balance.ObservedAt == nil || balance.ObservedAt.CheckValid() != nil { + return fmt.Errorf("invalid credit balance response: missing or invalid observation time") + } + if output == "json" { + data, err := (protojson.MarshalOptions{Indent: " "}).Marshal(balance) + if err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", data) + return err + } + + // Keep the calculation in integers so even large balances remain exact. + minor := balance.GetRemainingCreditMinor() + scale, _ := currency.Standard.Rounding(unit) + divisor := int64(1) + for range scale { + divisor *= 10 + } + amount := strconv.FormatInt(minor/divisor, 10) + if scale > 0 { + amount += fmt.Sprintf(".%0*d", scale, minor%divisor) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Remaining credits: %s %s\nObserved at: %s\n", + unit, amount, balance.ObservedAt.AsTime().UTC().Format(time.RFC3339Nano)) + return err + }, + } + cmd.Flags().StringVar(&token, "token", "", "Depot organization API token (read-only tokens supported)") + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (json)") + return cmd +} diff --git a/pkg/cmd/org/credits_test.go b/pkg/cmd/org/credits_test.go new file mode 100644 index 00000000..607f6072 --- /dev/null +++ b/pkg/cmd/org/credits_test.go @@ -0,0 +1,228 @@ +package org + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "math" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "buf.build/gen/go/depot/api/connectrpc/go/depot/core/v1/corev1connect" + corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1" + "connectrpc.com/connect" + "github.com/spf13/viper" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var creditObservedAt = time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + +type creditHandlerFunc func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) + +func (f creditHandlerFunc) GetCreditBalance(ctx context.Context, req *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + return f(ctx, req) +} + +func startCreditServer(t *testing.T, handler creditHandlerFunc) { + t.Helper() + _, h := corev1connect.NewCreditServiceHandler(handler) + server := httptest.NewServer(h2c.NewHandler(h, &http2.Server{})) + t.Cleanup(server.Close) + t.Setenv("DEPOT_API_URL", server.URL) + t.Setenv("DEPOT_TOKEN", "depot_reader_test") +} + +func creditResponse(amount int64, currency string) *connect.Response[corev1.GetCreditBalanceResponse] { + return connect.NewResponse(&corev1.GetCreditBalanceResponse{ + RemainingCreditMinor: proto.Int64(amount), + Currency: currency, + ObservedAt: timestamppb.New(creditObservedAt), + }) +} + +func executeCredits(ctx context.Context, args ...string) (string, error) { + cmd := NewCmdOrg() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(append([]string{"credits"}, args...)) + err := cmd.ExecuteContext(ctx) + return out.String(), err +} + +func TestCreditsHumanOutput(t *testing.T) { + for _, tt := range []struct { + currency string + minor int64 + want string + }{ + {"USD", 12500, "USD 125.00"}, + {"USD", 0, "USD 0.00"}, + {"EUR", 1, "EUR 0.01"}, + {"JPY", 1250, "JPY 1250"}, + {"KWD", 1250, "KWD 1.250"}, + {"USD", math.MaxInt64, "USD 92233720368547758.07"}, + } { + t.Run(tt.want, func(t *testing.T) { + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + return creditResponse(tt.minor, tt.currency), nil + }) + out, err := executeCredits(context.Background()) + if err != nil { + t.Fatal(err) + } + if want := "Remaining credits: " + tt.want + "\nObserved at: 2026-10-27T12:00:00Z\n"; out != want { + t.Fatalf("output = %q, want %q", out, want) + } + }) + } +} + +func TestCreditsJSONPreservesExactMinorUnits(t *testing.T) { + for _, amount := range []int64{0, math.MaxInt64} { + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + return creditResponse(amount, "USD"), nil + }) + out, err := executeCredits(context.Background(), "-o", "json") + if err != nil { + t.Fatal(err) + } + var got struct { + RemainingCreditMinor string `json:"remainingCreditMinor"` + Currency string `json:"currency"` + ObservedAt string `json:"observedAt"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + wantAmount := "0" + if amount == math.MaxInt64 { + wantAmount = "9223372036854775807" + } + if got.RemainingCreditMinor != wantAmount || got.Currency != "USD" || got.ObservedAt != "2026-10-27T12:00:00Z" { + t.Fatalf("unexpected JSON response: %+v", got) + } + } +} + +func TestCreditsUsesTokenWithoutOrganizationOverride(t *testing.T) { + for _, tt := range []struct { + name, flag, env, stored, want string + }{ + {"flag", "explicit-token", "env-token", "stored-token", "explicit-token"}, + {"environment", "", "depot_reader_env", "stored-token", "depot_reader_env"}, + {"configuration", "", "", "stored-token", "stored-token"}, + } { + t.Run(tt.name, func(t *testing.T) { + requests := make(chan *connect.Request[corev1.GetCreditBalanceRequest], 1) + startCreditServer(t, func(_ context.Context, req *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + requests <- req + return creditResponse(12500, "USD"), nil + }) + t.Setenv("DEPOT_TOKEN", tt.env) + viper.Set("api_token", tt.stored) + viper.Set("org_id", "other-organization") + t.Cleanup(viper.Reset) + out, err := executeCredits(context.Background(), "--token", tt.flag) + if err != nil { + t.Fatal(err) + } + req := <-requests + if got := req.Header().Get("Authorization"); got != "Bearer "+tt.want { + t.Fatalf("authorization = %q", got) + } + if req.Header().Get("X-Depot-Org") != "" { + t.Fatal("current organization must not override the token's organization") + } + if !strings.Contains(out, "USD 125.00") || !strings.HasPrefix(req.Header().Get("User-Agent"), "depot-cli/") { + t.Fatal("missing balance output or CLI user agent") + } + }) + } +} + +func TestCreditsErrorsDoNotPrintBalanceOrRetry(t *testing.T) { + for _, code := range []connect.Code{connect.CodeResourceExhausted, connect.CodeUnavailable, connect.CodeUnauthenticated, connect.CodeFailedPrecondition, connect.CodeUnimplemented} { + t.Run(code.String(), func(t *testing.T) { + var calls atomic.Int32 + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + calls.Add(1) + err := connect.NewError(code, errors.New("test rejection")) + if code == connect.CodeResourceExhausted { + err.Meta().Set("Retry-After", "60") + } + return nil, err + }) + out, err := executeCredits(context.Background(), "--output", "json") + if connect.CodeOf(err) != code || out != "" || calls.Load() != 1 { + t.Fatalf("output = %q, error = %v, requests = %d", out, err, calls.Load()) + } + if code == connect.CodeResourceExhausted && !strings.Contains(err.Error(), "retry after 60 seconds") { + t.Fatalf("retry delay missing: %v", err) + } + }) + } +} + +func TestCreditsRejectsMissingCredentialsAndInvalidFlagsBeforeRequest(t *testing.T) { + var calls atomic.Int32 + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + calls.Add(1) + return creditResponse(12500, "USD"), nil + }) + for _, key := range []string{"DEPOT_TOKEN", "DEPOT_API_TOKEN", "DEPOT_JIT_TOKEN", "DEPOT_CACHE_TOKEN"} { + t.Setenv(key, "") + } + viper.Set("api_token", "") + t.Cleanup(viper.Reset) + for _, tt := range []struct { + args []string + want string + }{ + {nil, "missing organization API token"}, + {[]string{"--output", "csv"}, "--output must be json"}, + {[]string{"unexpected"}, "unknown command"}, + {[]string{"--org", "other-org"}, "unknown flag"}, + } { + out, err := executeCredits(context.Background(), tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) || out != "" { + t.Fatalf("args = %v, output = %q, error = %v; want %q", tt.args, out, err, tt.want) + } + } + if calls.Load() != 0 { + t.Fatal("invalid input must not consume the polling allowance") + } +} + +func TestCreditsRejectsIncompleteBalances(t *testing.T) { + for _, tt := range []struct { + name string + modify func(*corev1.GetCreditBalanceResponse) + }{ + {"missing amount", func(r *corev1.GetCreditBalanceResponse) { r.RemainingCreditMinor = nil }}, + {"negative amount", func(r *corev1.GetCreditBalanceResponse) { r.RemainingCreditMinor = proto.Int64(-1) }}, + {"missing currency", func(r *corev1.GetCreditBalanceResponse) { r.Currency = "" }}, + {"missing observation time", func(r *corev1.GetCreditBalanceResponse) { r.ObservedAt = nil }}, + } { + t.Run(tt.name, func(t *testing.T) { + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + resp := creditResponse(12500, "USD") + tt.modify(resp.Msg) + return resp, nil + }) + out, err := executeCredits(context.Background(), "--output", "json") + if err == nil || !strings.Contains(err.Error(), "invalid credit balance response") || out != "" { + t.Fatalf("output = %q, error = %v", out, err) + } + }) + } +} diff --git a/pkg/cmd/org/org.go b/pkg/cmd/org/org.go index 7d828e0d..362132b2 100644 --- a/pkg/cmd/org/org.go +++ b/pkg/cmd/org/org.go @@ -15,6 +15,7 @@ func NewCmdOrg() *cobra.Command { cmd.AddCommand(NewCmdList()) cmd.AddCommand(NewCmdSwitch()) cmd.AddCommand(NewCmdShow()) + cmd.AddCommand(NewCmdCredits()) return cmd }