From cb7adaec49986ccc27bdcd8cd16bda12cc2d422e Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Thu, 22 Oct 2026 06:28:03 +0000 Subject: [PATCH 1/2] feat(cmd/email): fall back to the CLI login token for Z-Send REST commands `email send`, `email scheduled list|get|cancel` and `email batch list|get` required --api-key or ZSEND_API_KEY and rejected anything without the zs_ prefix, so a logged-in user could not use them at all. The Z-Send REST API also accepts the CLI login token. Resolve the credential in one place (Factory.ResolveZSendAPIKey): the --api-key flag, then ZSEND_API_KEY, then the logged-in CLI token. The zs_ prefix check now applies only to explicitly supplied keys. Refs: ZEA-10524 Co-Authored-By: Claude Opus 5.5 --- internal/cmd/email/batch/get/get.go | 15 ++--- internal/cmd/email/batch/list/list.go | 16 ++---- internal/cmd/email/scheduled/cancel/cancel.go | 17 ++---- internal/cmd/email/scheduled/get/get.go | 15 ++--- internal/cmd/email/scheduled/list/list.go | 16 ++---- internal/cmd/email/send/send.go | 18 +++--- internal/cmdutil/zsend.go | 44 +++++++++++++++ internal/cmdutil/zsend_test.go | 56 +++++++++++++++++++ 8 files changed, 133 insertions(+), 64 deletions(-) create mode 100644 internal/cmdutil/zsend.go create mode 100644 internal/cmdutil/zsend_test.go diff --git a/internal/cmd/email/batch/get/get.go b/internal/cmd/email/batch/get/get.go index 2833fcf..831349e 100644 --- a/internal/cmd/email/batch/get/get.go +++ b/internal/cmd/email/batch/get/get.go @@ -3,7 +3,6 @@ package get import ( "context" "fmt" - "os" "strings" "github.com/briandowns/spinner" @@ -29,7 +28,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Z-Send API key (or set ZSEND_API_KEY)") + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", cmdutil.ZSendAPIKeyFlagUsage) cmd.Flags().StringVar(&opts.id, "id", "", "Batch job ID") return cmd @@ -64,15 +63,11 @@ func runGetNonInteractive(f *cmdutil.Factory, opts Options) error { } func getBatchJob(f *cmdutil.Factory, opts Options) error { - if opts.apiKey == "" { - opts.apiKey = os.Getenv("ZSEND_API_KEY") - } - if opts.apiKey == "" { - return fmt.Errorf("Z-Send API key is required (--api-key or ZSEND_API_KEY)") - } - if !strings.HasPrefix(opts.apiKey, "zs_") { - return fmt.Errorf("invalid API key format: must start with zs_") + apiKey, err := f.ResolveZSendAPIKey(opts.apiKey) + if err != nil { + return err } + opts.apiKey = apiKey s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, spinner.WithColor(cmdutil.SpinnerColor), diff --git a/internal/cmd/email/batch/list/list.go b/internal/cmd/email/batch/list/list.go index dcfb289..e1d8998 100644 --- a/internal/cmd/email/batch/list/list.go +++ b/internal/cmd/email/batch/list/list.go @@ -3,8 +3,6 @@ package list import ( "context" "fmt" - "os" - "strings" "github.com/briandowns/spinner" "github.com/spf13/cobra" @@ -32,7 +30,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Z-Send API key (or set ZSEND_API_KEY)") + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", cmdutil.ZSendAPIKeyFlagUsage) cmd.Flags().StringVar(&opts.status, "status", "", "Filter by status (pending/processing/completed/failed)") cmd.Flags().IntVar(&opts.page, "page", 1, "Page number") cmd.Flags().IntVar(&opts.perPage, "per-page", 20, "Items per page (max 100)") @@ -48,15 +46,11 @@ func runList(f *cmdutil.Factory, opts Options) error { return fmt.Errorf("per-page must be between 1 and 100") } - if opts.apiKey == "" { - opts.apiKey = os.Getenv("ZSEND_API_KEY") - } - if opts.apiKey == "" { - return fmt.Errorf("Z-Send API key is required (--api-key or ZSEND_API_KEY)") - } - if !strings.HasPrefix(opts.apiKey, "zs_") { - return fmt.Errorf("invalid API key format: must start with zs_") + apiKey, err := f.ResolveZSendAPIKey(opts.apiKey) + if err != nil { + return err } + opts.apiKey = apiKey var status *string if opts.status != "" { diff --git a/internal/cmd/email/scheduled/cancel/cancel.go b/internal/cmd/email/scheduled/cancel/cancel.go index 7de86fa..f85e209 100644 --- a/internal/cmd/email/scheduled/cancel/cancel.go +++ b/internal/cmd/email/scheduled/cancel/cancel.go @@ -3,7 +3,6 @@ package cancel import ( "context" "fmt" - "os" "strings" "github.com/briandowns/spinner" @@ -28,7 +27,7 @@ func NewCmdCancel(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Z-Send API key (or set ZSEND_API_KEY)") + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", cmdutil.ZSendAPIKeyFlagUsage) cmd.Flags().StringVar(&opts.id, "id", "", "Scheduled email ID") return cmd @@ -72,22 +71,18 @@ func runCancelNonInteractive(f *cmdutil.Factory, opts Options) error { } func doCancel(f *cmdutil.Factory, opts Options) error { - if opts.apiKey == "" { - opts.apiKey = os.Getenv("ZSEND_API_KEY") - } - if opts.apiKey == "" { - return fmt.Errorf("Z-Send API key is required (--api-key or ZSEND_API_KEY)") - } - if !strings.HasPrefix(opts.apiKey, "zs_") { - return fmt.Errorf("invalid API key format: must start with zs_") + apiKey, err := f.ResolveZSendAPIKey(opts.apiKey) + if err != nil { + return err } + opts.apiKey = apiKey s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, spinner.WithColor(cmdutil.SpinnerColor), spinner.WithSuffix(" Cancelling scheduled email..."), ) s.Start() - err := f.ApiClient.CancelZSendScheduledEmail(context.Background(), opts.apiKey, opts.id) + err = f.ApiClient.CancelZSendScheduledEmail(context.Background(), opts.apiKey, opts.id) s.Stop() if err != nil { return err diff --git a/internal/cmd/email/scheduled/get/get.go b/internal/cmd/email/scheduled/get/get.go index 200859f..23fb63c 100644 --- a/internal/cmd/email/scheduled/get/get.go +++ b/internal/cmd/email/scheduled/get/get.go @@ -3,7 +3,6 @@ package get import ( "context" "fmt" - "os" "strings" "github.com/briandowns/spinner" @@ -29,7 +28,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Z-Send API key (or set ZSEND_API_KEY)") + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", cmdutil.ZSendAPIKeyFlagUsage) cmd.Flags().StringVar(&opts.id, "id", "", "Scheduled email ID") return cmd @@ -64,15 +63,11 @@ func runGetNonInteractive(f *cmdutil.Factory, opts Options) error { } func getScheduledEmail(f *cmdutil.Factory, opts Options) error { - if opts.apiKey == "" { - opts.apiKey = os.Getenv("ZSEND_API_KEY") - } - if opts.apiKey == "" { - return fmt.Errorf("Z-Send API key is required (--api-key or ZSEND_API_KEY)") - } - if !strings.HasPrefix(opts.apiKey, "zs_") { - return fmt.Errorf("invalid API key format: must start with zs_") + apiKey, err := f.ResolveZSendAPIKey(opts.apiKey) + if err != nil { + return err } + opts.apiKey = apiKey s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, spinner.WithColor(cmdutil.SpinnerColor), diff --git a/internal/cmd/email/scheduled/list/list.go b/internal/cmd/email/scheduled/list/list.go index 35fa379..306ea2e 100644 --- a/internal/cmd/email/scheduled/list/list.go +++ b/internal/cmd/email/scheduled/list/list.go @@ -3,8 +3,6 @@ package list import ( "context" "fmt" - "os" - "strings" "github.com/briandowns/spinner" "github.com/spf13/cobra" @@ -32,7 +30,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Z-Send API key (or set ZSEND_API_KEY)") + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", cmdutil.ZSendAPIKeyFlagUsage) cmd.Flags().StringVar(&opts.status, "status", "", "Filter by status (scheduled/sent/cancelled/failed)") cmd.Flags().IntVar(&opts.page, "page", 1, "Page number") cmd.Flags().IntVar(&opts.perPage, "per-page", 20, "Items per page (max 100)") @@ -48,15 +46,11 @@ func runList(f *cmdutil.Factory, opts Options) error { return fmt.Errorf("per-page must be between 1 and 100") } - if opts.apiKey == "" { - opts.apiKey = os.Getenv("ZSEND_API_KEY") - } - if opts.apiKey == "" { - return fmt.Errorf("Z-Send API key is required (--api-key or ZSEND_API_KEY)") - } - if !strings.HasPrefix(opts.apiKey, "zs_") { - return fmt.Errorf("invalid API key format: must start with zs_") + apiKey, err := f.ResolveZSendAPIKey(opts.apiKey) + if err != nil { + return err } + opts.apiKey = apiKey var status *string if opts.status != "" { diff --git a/internal/cmd/email/send/send.go b/internal/cmd/email/send/send.go index 615c9dc..059908d 100644 --- a/internal/cmd/email/send/send.go +++ b/internal/cmd/email/send/send.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "strings" "github.com/briandowns/spinner" "github.com/spf13/cobra" @@ -42,7 +41,8 @@ Modes: - --batch-file: sends a batch from a JSON file (POST /emails/batch, max 100) Authentication: - Pass your Z-Send API key via --api-key or the ZSEND_API_KEY environment variable. + Pass a Z-Send API key (zs_...) via --api-key or the ZSEND_API_KEY environment + variable. When neither is set, the logged-in CLI token is used. Batch file format (JSON array): [ @@ -54,7 +54,7 @@ Batch file format (JSON array): }, } - cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Z-Send API key (or set ZSEND_API_KEY)") + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", cmdutil.ZSendAPIKeyFlagUsage) cmd.Flags().StringVar(&opts.from, "from", "", "Sender address (required unless --batch-file)") cmd.Flags().StringArrayVar(&opts.to, "to", nil, "Recipient(s), can be repeated") cmd.Flags().StringArrayVar(&opts.cc, "cc", nil, "CC recipient(s)") @@ -70,15 +70,11 @@ Batch file format (JSON array): } func runSend(f *cmdutil.Factory, opts Options) error { - if opts.apiKey == "" { - opts.apiKey = os.Getenv("ZSEND_API_KEY") - } - if opts.apiKey == "" { - return fmt.Errorf("Z-Send API key is required (--api-key or ZSEND_API_KEY)") - } - if !strings.HasPrefix(opts.apiKey, "zs_") { - return fmt.Errorf("invalid API key format: must start with zs_") + apiKey, err := f.ResolveZSendAPIKey(opts.apiKey) + if err != nil { + return err } + opts.apiKey = apiKey if opts.batchFile != "" { return runBatch(f, opts) diff --git a/internal/cmdutil/zsend.go b/internal/cmdutil/zsend.go new file mode 100644 index 0000000..315c2cf --- /dev/null +++ b/internal/cmdutil/zsend.go @@ -0,0 +1,44 @@ +package cmdutil + +import ( + "fmt" + "os" + "strings" +) + +// ZSendAPIKeyEnv is the environment variable read when --api-key is not set. +const ZSendAPIKeyEnv = "ZSEND_API_KEY" + +// ZSendAPIKeyFlagUsage is the shared help text of the --api-key flag on +// Z-Send REST commands. +const ZSendAPIKeyFlagUsage = "Z-Send API key (or set ZSEND_API_KEY); defaults to the logged-in CLI token" + +// ResolveZSendAPIKey returns the credential for the Z-Send REST API. +// +// Resolution order: +// 1. the --api-key flag value +// 2. the ZSEND_API_KEY environment variable +// 3. the logged-in CLI token, which the Z-Send REST API also accepts +// +// An explicitly supplied key (1 or 2) must be a Z-Send key with the zs_ +// prefix; the CLI token is used as-is. +func (f *Factory) ResolveZSendAPIKey(flagValue string) (string, error) { + apiKey := flagValue + if apiKey == "" { + apiKey = os.Getenv(ZSendAPIKeyEnv) + } + if apiKey != "" { + if !strings.HasPrefix(apiKey, "zs_") { + return "", fmt.Errorf("invalid API key format: must start with zs_") + } + return apiKey, nil + } + + if f.Config != nil { + if token := f.Config.GetTokenString(); token != "" { + return token, nil + } + } + + return "", fmt.Errorf("Z-Send API key is required: pass --api-key, set ZSEND_API_KEY, or run `zeabur auth login`") +} diff --git a/internal/cmdutil/zsend_test.go b/internal/cmdutil/zsend_test.go new file mode 100644 index 0000000..089e19b --- /dev/null +++ b/internal/cmdutil/zsend_test.go @@ -0,0 +1,56 @@ +package cmdutil_test + +import ( + "testing" + + "github.com/zeabur/cli/internal/cmdutil" +) + +// tokenConfig is a stubConfig that reports a logged-in CLI token. +type tokenConfig struct { + stubConfig + token string +} + +func (c tokenConfig) GetTokenString() string { return c.token } + +func TestResolveZSendAPIKey(t *testing.T) { + const cliToken = "cli-login-token" + + tests := []struct { + name string + flag string + env string + token string + want string + wantErr string + }{ + {name: "flag wins", flag: "zs_flag", env: "zs_env", token: cliToken, want: "zs_flag"}, + {name: "env when flag unset", env: "zs_env", token: cliToken, want: "zs_env"}, + {name: "falls back to CLI token", token: cliToken, want: cliToken}, + {name: "invalid flag key", flag: "sk_flag", token: cliToken, wantErr: "invalid API key format: must start with zs_"}, + {name: "invalid env key", env: "sk_env", token: cliToken, wantErr: "invalid API key format: must start with zs_"}, + {name: "nothing configured", wantErr: "Z-Send API key is required: pass --api-key, set ZSEND_API_KEY, or run `zeabur auth login`"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(cmdutil.ZSendAPIKeyEnv, tt.env) + f := &cmdutil.Factory{Config: tokenConfig{token: tt.token}} + + got, err := f.ResolveZSendAPIKey(tt.flag) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("err = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} From 900582ceb82ff05a84be58a448e852ce9206179c Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Thu, 22 Oct 2026 06:28:03 +0000 Subject: [PATCH 2/2] feat(cmd)!: remove the ai-hub command group The production GraphQL API no longer serves AI Hub (aihubTenant, updateAIHubAutoRechargeSettings and every other AI Hub query or mutation except aihubRefundStatement), so `zeabur ai-hub status` and `zeabur ai-hub keys list` fail with HTTP 422. Remove the command group, its API client and models. BREAKING CHANGE: `zeabur ai-hub` and all of its subcommands are removed. Refs: ZEA-10524 Co-Authored-By: Claude Opus 5.5 --- .../cmd/ai-hub/add-balance/add_balance.go | 80 ---------- internal/cmd/ai-hub/ai-hub.go | 27 ---- .../cmd/ai-hub/auto-recharge/auto_recharge.go | 109 ------------- internal/cmd/ai-hub/keys/create/create.go | 71 --------- internal/cmd/ai-hub/keys/delete/delete.go | 107 ------------- internal/cmd/ai-hub/keys/keys.go | 23 --- internal/cmd/ai-hub/keys/list/list.go | 54 ------- internal/cmd/ai-hub/status/status.go | 60 ------- internal/cmd/ai-hub/usage/usage.go | 69 --------- internal/cmd/root/root.go | 2 - pkg/api/aihub.go | 125 --------------- pkg/api/interface.go | 11 -- pkg/model/aihub.go | 146 ------------------ 13 files changed, 884 deletions(-) delete mode 100644 internal/cmd/ai-hub/add-balance/add_balance.go delete mode 100644 internal/cmd/ai-hub/ai-hub.go delete mode 100644 internal/cmd/ai-hub/auto-recharge/auto_recharge.go delete mode 100644 internal/cmd/ai-hub/keys/create/create.go delete mode 100644 internal/cmd/ai-hub/keys/delete/delete.go delete mode 100644 internal/cmd/ai-hub/keys/keys.go delete mode 100644 internal/cmd/ai-hub/keys/list/list.go delete mode 100644 internal/cmd/ai-hub/status/status.go delete mode 100644 internal/cmd/ai-hub/usage/usage.go delete mode 100644 pkg/api/aihub.go delete mode 100644 pkg/model/aihub.go diff --git a/internal/cmd/ai-hub/add-balance/add_balance.go b/internal/cmd/ai-hub/add-balance/add_balance.go deleted file mode 100644 index 0b6b049..0000000 --- a/internal/cmd/ai-hub/add-balance/add_balance.go +++ /dev/null @@ -1,80 +0,0 @@ -package addbalance - -import ( - "context" - "fmt" - "strconv" - - "github.com/briandowns/spinner" - "github.com/cli/browser" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct { - amount int -} - -func NewCmdAddBalance(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "add-balance", - Short: "Add balance to AI Hub", - RunE: func(cmd *cobra.Command, args []string) error { - return runAddBalance(f, opts) - }, - } - - cmd.Flags().IntVar(&opts.amount, "amount", 0, "Amount to add in dollars") - - return cmd -} - -func runAddBalance(f *cmdutil.Factory, opts Options) error { - if opts.amount == 0 && f.Interactive { - input, err := f.Prompter.Input("Amount to add (in dollars): ", "") - if err != nil { - return err - } - amount, err := strconv.Atoi(input) - if err != nil { - return fmt.Errorf("invalid amount: %w", err) - } - opts.amount = amount - } - - if opts.amount <= 0 { - return fmt.Errorf("--amount is required and must be greater than 0") - } - - amountMillicents := opts.amount * 100000 - provider := "litellm" - - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Adding balance..."), - ) - s.Start() - result, err := f.ApiClient.AddAIHubBalance(context.Background(), amountMillicents, &provider) - s.Stop() - if err != nil { - checkoutURL := fmt.Sprintf("https://dash.zeabur.com/checkout?type=ai-hub&amount=%d", opts.amount) - f.Log.Infof("Failed to add balance: %s", err) - f.Log.Infof("Opening checkout page in browser...") - if openErr := browser.OpenURL(checkoutURL); openErr != nil { - f.Log.Infof("Please visit: %s", checkoutURL) - } - return nil - } - - if f.JSON { - return f.Printer.JSON(result) - } - - newBalanceDollars := float64(result.NewBalance) / 100000.0 - f.Log.Infof("Balance added successfully! New balance: $%.2f", newBalanceDollars) - - return nil -} diff --git a/internal/cmd/ai-hub/ai-hub.go b/internal/cmd/ai-hub/ai-hub.go deleted file mode 100644 index 4b3381b..0000000 --- a/internal/cmd/ai-hub/ai-hub.go +++ /dev/null @@ -1,27 +0,0 @@ -package aihub - -import ( - "github.com/spf13/cobra" - - addBalanceCmd "github.com/zeabur/cli/internal/cmd/ai-hub/add-balance" - autoRechargeCmd "github.com/zeabur/cli/internal/cmd/ai-hub/auto-recharge" - keysCmd "github.com/zeabur/cli/internal/cmd/ai-hub/keys" - statusCmd "github.com/zeabur/cli/internal/cmd/ai-hub/status" - usageCmd "github.com/zeabur/cli/internal/cmd/ai-hub/usage" - "github.com/zeabur/cli/internal/cmdutil" -) - -func NewCmdAIHub(f *cmdutil.Factory) *cobra.Command { - cmd := &cobra.Command{ - Use: "ai-hub ", - Short: "Manage AI Hub", - } - - cmd.AddCommand(statusCmd.NewCmdStatus(f)) - cmd.AddCommand(keysCmd.NewCmdKeys(f)) - cmd.AddCommand(addBalanceCmd.NewCmdAddBalance(f)) - cmd.AddCommand(usageCmd.NewCmdUsage(f)) - cmd.AddCommand(autoRechargeCmd.NewCmdAutoRecharge(f)) - - return cmd -} diff --git a/internal/cmd/ai-hub/auto-recharge/auto_recharge.go b/internal/cmd/ai-hub/auto-recharge/auto_recharge.go deleted file mode 100644 index a354c81..0000000 --- a/internal/cmd/ai-hub/auto-recharge/auto_recharge.go +++ /dev/null @@ -1,109 +0,0 @@ -package autorecharge - -import ( - "context" - "fmt" - "strconv" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct { - threshold int - amount int -} - -func NewCmdAutoRecharge(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "auto-recharge", - Short: "Configure AI Hub auto-recharge settings", - Long: "Configure auto-recharge settings. Set both --threshold and --amount to 0 to disable auto-recharge.", - RunE: func(cmd *cobra.Command, args []string) error { - return runAutoRecharge(f, opts) - }, - } - - cmd.Flags().IntVar(&opts.threshold, "threshold", -1, "Balance threshold in dollars to trigger recharge (0 to disable)") - cmd.Flags().IntVar(&opts.amount, "amount", -1, "Amount in dollars to recharge (0 to disable)") - - return cmd -} - -func runAutoRecharge(f *cmdutil.Factory, opts Options) error { - if f.Interactive && (opts.threshold < 0 || opts.amount < 0) { - return runAutoRechargeInteractive(f, opts) - } - return runAutoRechargeNonInteractive(f, opts) -} - -func runAutoRechargeInteractive(f *cmdutil.Factory, opts Options) error { - if opts.threshold < 0 { - input, err := f.Prompter.Input("Recharge threshold (in dollars, 0 to disable): ", "") - if err != nil { - return err - } - threshold, err := strconv.Atoi(input) - if err != nil { - return fmt.Errorf("invalid threshold: %w", err) - } - opts.threshold = threshold - } - - if opts.amount < 0 { - input, err := f.Prompter.Input("Recharge amount (in dollars, 0 to disable): ", "") - if err != nil { - return err - } - amount, err := strconv.Atoi(input) - if err != nil { - return fmt.Errorf("invalid amount: %w", err) - } - opts.amount = amount - } - - return updateAutoRecharge(f, opts) -} - -func runAutoRechargeNonInteractive(f *cmdutil.Factory, opts Options) error { - if opts.threshold < 0 || opts.amount < 0 { - return fmt.Errorf("both --threshold and --amount are required") - } - - return updateAutoRecharge(f, opts) -} - -func updateAutoRecharge(f *cmdutil.Factory, opts Options) error { - thresholdMillicents := opts.threshold * 100000 - amountMillicents := opts.amount * 100000 - - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Updating auto-recharge settings..."), - ) - s.Start() - result, err := f.ApiClient.UpdateAIHubAutoRechargeSettings(context.Background(), thresholdMillicents, amountMillicents) - s.Stop() - if err != nil { - return err - } - - if f.JSON { - return f.Printer.JSON(result) - } - - if result.AutoRechargeThreshold == 0 && result.AutoRechargeAmount == 0 { - f.Log.Infof("Auto-recharge disabled") - } else { - f.Log.Infof("Auto-recharge updated: threshold $%.2f, amount $%.2f", - float64(result.AutoRechargeThreshold)/100000.0, - float64(result.AutoRechargeAmount)/100000.0, - ) - } - - return nil -} diff --git a/internal/cmd/ai-hub/keys/create/create.go b/internal/cmd/ai-hub/keys/create/create.go deleted file mode 100644 index 5f959bb..0000000 --- a/internal/cmd/ai-hub/keys/create/create.go +++ /dev/null @@ -1,71 +0,0 @@ -package create - -import ( - "context" - "fmt" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct { - alias string -} - -func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "create", - Short: "Create a new AI Hub API key", - RunE: func(cmd *cobra.Command, args []string) error { - return runCreate(f, opts) - }, - } - - cmd.Flags().StringVar(&opts.alias, "alias", "", "Alias for the API key") - - return cmd -} - -func runCreate(f *cmdutil.Factory, opts Options) error { - if opts.alias == "" && f.Interactive { - alias, err := f.Prompter.Input("Key alias (optional): ", "") - if err != nil { - return err - } - opts.alias = alias - } - - var aliasPtr *string - if opts.alias != "" { - aliasPtr = &opts.alias - } - - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Creating AI Hub key..."), - ) - s.Start() - result, err := f.ApiClient.CreateAIHubKey(context.Background(), aliasPtr) - s.Stop() - if err != nil { - return err - } - - if f.JSON { - return f.Printer.JSON(result) - } - - f.Log.Infof("Key created successfully!") - f.Log.Infof("Key ID: %s", result.Key.KeyID) - if result.Key.Alias != "" { - f.Log.Infof("Alias: %s", result.Key.Alias) - } - fmt.Printf("API Key: %s\n", result.APIKey) - f.Log.Infof("WARNING: This API key will only be shown once. Please save it now.") - - return nil -} diff --git a/internal/cmd/ai-hub/keys/delete/delete.go b/internal/cmd/ai-hub/keys/delete/delete.go deleted file mode 100644 index 8266391..0000000 --- a/internal/cmd/ai-hub/keys/delete/delete.go +++ /dev/null @@ -1,107 +0,0 @@ -package delete - -import ( - "context" - "fmt" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct { - keyID string -} - -func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete an AI Hub API key", - RunE: func(cmd *cobra.Command, args []string) error { - return runDelete(f, opts) - }, - } - - cmd.Flags().StringVar(&opts.keyID, "key-id", "", "ID of the key to delete") - - return cmd -} - -func runDelete(f *cmdutil.Factory, opts Options) error { - if f.Interactive { - return runDeleteInteractive(f, opts) - } - return runDeleteNonInteractive(f, opts) -} - -func runDeleteInteractive(f *cmdutil.Factory, opts Options) error { - if opts.keyID == "" { - // Fetch keys to let user select - tenant, err := f.ApiClient.GetAIHubTenant(context.Background()) - if err != nil { - return err - } - - if len(tenant.Keys) == 0 { - return fmt.Errorf("no API keys found") - } - - options := make([]string, 0, len(tenant.Keys)) - for _, key := range tenant.Keys { - label := key.KeyID - if key.Alias != "" { - label = fmt.Sprintf("%s (%s)", key.Alias, key.KeyID) - } - options = append(options, label) - } - - selected, err := f.Prompter.Select("Select a key to delete:", "", options) - if err != nil { - return err - } - - opts.keyID = tenant.Keys[selected].KeyID - } - - confirmed, err := f.Prompter.Confirm("Are you sure you want to delete this key?", false) - if err != nil { - return err - } - if !confirmed { - return nil - } - - return deleteKey(f, opts) -} - -func runDeleteNonInteractive(f *cmdutil.Factory, opts Options) error { - if opts.keyID == "" { - return fmt.Errorf("--key-id is required") - } - - return deleteKey(f, opts) -} - -func deleteKey(f *cmdutil.Factory, opts Options) error { - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Deleting AI Hub key..."), - ) - s.Start() - err := f.ApiClient.DeleteAIHubKey(context.Background(), opts.keyID) - s.Stop() - if err != nil { - return err - } - - if f.JSON { - return f.Printer.JSON(map[string]string{"status": "success", "keyID": opts.keyID}) - } - - f.Log.Infof("Key %s deleted successfully", opts.keyID) - - return nil -} diff --git a/internal/cmd/ai-hub/keys/keys.go b/internal/cmd/ai-hub/keys/keys.go deleted file mode 100644 index 9d438df..0000000 --- a/internal/cmd/ai-hub/keys/keys.go +++ /dev/null @@ -1,23 +0,0 @@ -package keys - -import ( - "github.com/spf13/cobra" - - keysCreateCmd "github.com/zeabur/cli/internal/cmd/ai-hub/keys/create" - keysDeleteCmd "github.com/zeabur/cli/internal/cmd/ai-hub/keys/delete" - keysListCmd "github.com/zeabur/cli/internal/cmd/ai-hub/keys/list" - "github.com/zeabur/cli/internal/cmdutil" -) - -func NewCmdKeys(f *cmdutil.Factory) *cobra.Command { - cmd := &cobra.Command{ - Use: "keys ", - Short: "Manage AI Hub API keys", - } - - cmd.AddCommand(keysListCmd.NewCmdList(f)) - cmd.AddCommand(keysCreateCmd.NewCmdCreate(f)) - cmd.AddCommand(keysDeleteCmd.NewCmdDelete(f)) - - return cmd -} diff --git a/internal/cmd/ai-hub/keys/list/list.go b/internal/cmd/ai-hub/keys/list/list.go deleted file mode 100644 index cbc0e32..0000000 --- a/internal/cmd/ai-hub/keys/list/list.go +++ /dev/null @@ -1,54 +0,0 @@ -package list - -import ( - "context" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" - "github.com/zeabur/cli/pkg/model" -) - -type Options struct{} - -func NewCmdList(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "list", - Short: "List AI Hub API keys", - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, args []string) error { - return runList(f, opts) - }, - } - - return cmd -} - -func runList(f *cmdutil.Factory, opts Options) error { - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Fetching AI Hub keys..."), - ) - s.Start() - tenant, err := f.ApiClient.GetAIHubTenant(context.Background()) - s.Stop() - if err != nil { - return err - } - - keys := model.AIHubKeys(tenant.Keys) - - if f.JSON { - if len(keys) == 0 { - return f.Printer.JSON([]any{}) - } - return f.Printer.JSON(keys) - } - - f.Printer.Table(keys.Header(), keys.Rows()) - - return nil -} diff --git a/internal/cmd/ai-hub/status/status.go b/internal/cmd/ai-hub/status/status.go deleted file mode 100644 index 59ac5be..0000000 --- a/internal/cmd/ai-hub/status/status.go +++ /dev/null @@ -1,60 +0,0 @@ -package status - -import ( - "context" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" -) - -type Options struct{} - -func NewCmdStatus(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "status", - Short: "Show AI Hub tenant status", - RunE: func(cmd *cobra.Command, args []string) error { - return runStatus(f, opts) - }, - } - - return cmd -} - -func runStatus(f *cmdutil.Factory, opts Options) error { - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Fetching AI Hub status..."), - ) - s.Start() - tenant, err := f.ApiClient.GetAIHubTenant(context.Background()) - s.Stop() - if err != nil { - return err - } - - if f.JSON { - return f.Printer.JSON(tenant) - } - - balanceDollars := float64(tenant.Balance) / 100000.0 - - f.Log.Infof("Balance: $%.2f", balanceDollars) - f.Log.Infof("Keys: %d", len(tenant.Keys)) - f.Log.Infof("Provider: %s", tenant.Provider) - - if tenant.AutoRechargeThreshold > 0 || tenant.AutoRechargeAmount > 0 { - f.Log.Infof("Auto-Recharge: threshold $%.2f, amount $%.2f", - float64(tenant.AutoRechargeThreshold)/100000.0, - float64(tenant.AutoRechargeAmount)/100000.0, - ) - } else { - f.Log.Infof("Auto-Recharge: disabled") - } - - return nil -} diff --git a/internal/cmd/ai-hub/usage/usage.go b/internal/cmd/ai-hub/usage/usage.go deleted file mode 100644 index 97106a3..0000000 --- a/internal/cmd/ai-hub/usage/usage.go +++ /dev/null @@ -1,69 +0,0 @@ -package usage - -import ( - "context" - "fmt" - "time" - - "github.com/briandowns/spinner" - "github.com/spf13/cobra" - - "github.com/zeabur/cli/internal/cmdutil" - "github.com/zeabur/cli/pkg/model" -) - -type Options struct { - month string -} - -func NewCmdUsage(f *cmdutil.Factory) *cobra.Command { - opts := Options{} - - cmd := &cobra.Command{ - Use: "usage", - Short: "Show AI Hub monthly usage", - RunE: func(cmd *cobra.Command, args []string) error { - return runUsage(f, opts) - }, - } - - cmd.Flags().StringVar(&opts.month, "month", "", "Month in YYYY-MM format") - - return cmd -} - -func runUsage(f *cmdutil.Factory, opts Options) error { - var monthPtr *string - if opts.month != "" { - if _, err := time.Parse("2006-01", opts.month); err != nil { - return fmt.Errorf("--month must be in YYYY-MM format: %w", err) - } - monthPtr = &opts.month - } - - s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, - spinner.WithColor(cmdutil.SpinnerColor), - spinner.WithSuffix(" Fetching AI Hub usage..."), - ) - s.Start() - usage, err := f.ApiClient.GetAIHubMonthlyUsage(context.Background(), monthPtr) - s.Stop() - if err != nil { - return err - } - - if f.JSON { - return f.Printer.JSON(usage) - } - - f.Log.Infof("Total Spend: $%.6f", usage.TotalSpend) - - if len(usage.ModelsCost) > 0 { - f.Log.Infof("") - f.Log.Infof("Per-Model Breakdown:") - costs := model.AIHubModelCosts(usage.ModelsCost) - f.Printer.Table(costs.Header(), costs.Rows()) - } - - return nil -} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 64b5c0c..e32d11d 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -11,7 +11,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - aihubCmd "github.com/zeabur/cli/internal/cmd/ai-hub" apiCmd "github.com/zeabur/cli/internal/cmd/api" authCmd "github.com/zeabur/cli/internal/cmd/auth" completionCmd "github.com/zeabur/cli/internal/cmd/completion" @@ -186,7 +185,6 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman cmd.AddCommand(variableCmd.NewCmdVariable(f)) cmd.AddCommand(emailCmd.NewCmdEmail(f)) cmd.AddCommand(fileCmd.NewCmdFile(f)) - cmd.AddCommand(aihubCmd.NewCmdAIHub(f)) cmd.AddCommand(workspaceCmd.NewCmdWorkspace(f)) cmd.AddCommand(apiCmd.NewCmdAPI(f)) diff --git a/pkg/api/aihub.go b/pkg/api/aihub.go deleted file mode 100644 index b525df9..0000000 --- a/pkg/api/aihub.go +++ /dev/null @@ -1,125 +0,0 @@ -package api - -import ( - "context" - "fmt" - "time" - - "github.com/zeabur/cli/pkg/model" -) - -// GetAIHubTenant returns the current user's AI Hub tenant information. -func (c *client) GetAIHubTenant(ctx context.Context) (*model.AIHubTenant, error) { - var query struct { - AIHubTenant model.AIHubTenant `graphql:"aihubTenant"` - } - - err := c.Query(ctx, &query, nil) - if err != nil { - return nil, err - } - - return &query.AIHubTenant, nil -} - -// AddAIHubBalance adds balance to the AI Hub account. -func (c *client) AddAIHubBalance(ctx context.Context, amount int, provider *string) (*model.AddAIHubBalanceResult, error) { - var mutation struct { - AddAIHubBalance model.AddAIHubBalanceResult `graphql:"addAIHubBalance(amount: $amount, provider: $provider)"` - } - - err := c.Mutate(ctx, &mutation, V{ - "amount": amount, - "provider": provider, - }) - if err != nil { - return nil, err - } - - return &mutation.AddAIHubBalance, nil -} - -// CreateAIHubKey creates a new AI Hub API key. -func (c *client) CreateAIHubKey(ctx context.Context, alias *string) (*model.CreateAIHubKeyResult, error) { - var mutation struct { - CreateAIHubKey model.CreateAIHubKeyResult `graphql:"createAIHubKey(alias: $alias)"` - } - - err := c.Mutate(ctx, &mutation, V{ - "alias": alias, - }) - if err != nil { - return nil, err - } - - return &mutation.CreateAIHubKey, nil -} - -// DeleteAIHubKey deletes an AI Hub API key by its key ID. -func (c *client) DeleteAIHubKey(ctx context.Context, keyID string) error { - var mutation struct { - DeleteAIHubKey bool `graphql:"deleteAIHubKey(keyID: $keyID)"` - } - - err := c.Mutate(ctx, &mutation, V{ - "keyID": keyID, - }) - if err != nil { - return err - } - if !mutation.DeleteAIHubKey { - return fmt.Errorf("failed to delete AI Hub key %q", keyID) - } - - return nil -} - -// UpdateAIHubAutoRechargeSettings updates the auto-recharge settings. -func (c *client) UpdateAIHubAutoRechargeSettings(ctx context.Context, threshold, amount int) (*model.UpdateAIHubAutoRechargeSettingsResult, error) { - var mutation struct { - UpdateAIHubAutoRechargeSettings model.UpdateAIHubAutoRechargeSettingsResult `graphql:"updateAIHubAutoRechargeSettings(threshold: $threshold, amount: $amount)"` - } - - err := c.Mutate(ctx, &mutation, V{ - "threshold": threshold, - "amount": amount, - }) - if err != nil { - return nil, err - } - - return &mutation.UpdateAIHubAutoRechargeSettings, nil -} - -// GetAIHubSpendLogs returns spend logs optionally filtered by date range. -func (c *client) GetAIHubSpendLogs(ctx context.Context, startDate, endDate *time.Time) ([]model.AIHubSpendLog, error) { - var query struct { - AIHubSpendLogs []model.AIHubSpendLog `graphql:"aihubSpendLogs(startDate: $startDate, endDate: $endDate)"` - } - - err := c.Query(ctx, &query, V{ - "startDate": startDate, - "endDate": endDate, - }) - if err != nil { - return nil, err - } - - return query.AIHubSpendLogs, nil -} - -// GetAIHubMonthlyUsage returns monthly usage summary for the given month. -func (c *client) GetAIHubMonthlyUsage(ctx context.Context, month *string) (*model.AIHubMonthlyUsage, error) { - var query struct { - AIHubMonthlyUsage model.AIHubMonthlyUsage `graphql:"aihubMonthlyUsage(month: $month)"` - } - - err := c.Query(ctx, &query, V{ - "month": month, - }) - if err != nil { - return nil, err - } - - return &query.AIHubMonthlyUsage, nil -} diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 9785391..5077f51 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -21,7 +21,6 @@ type Client interface { DomainAPI VariableAPI ServerAPI - AIHubAPI ZSendAPI RegisteredDomainAPI FileAPI @@ -149,16 +148,6 @@ type ( GetRepoBranchesByRepoID(repoID int) ([]string, error) } - AIHubAPI interface { - GetAIHubTenant(ctx context.Context) (*model.AIHubTenant, error) - AddAIHubBalance(ctx context.Context, amount int, provider *string) (*model.AddAIHubBalanceResult, error) - CreateAIHubKey(ctx context.Context, alias *string) (*model.CreateAIHubKeyResult, error) - DeleteAIHubKey(ctx context.Context, keyID string) error - UpdateAIHubAutoRechargeSettings(ctx context.Context, threshold, amount int) (*model.UpdateAIHubAutoRechargeSettingsResult, error) - GetAIHubSpendLogs(ctx context.Context, startDate, endDate *time.Time) ([]model.AIHubSpendLog, error) - GetAIHubMonthlyUsage(ctx context.Context, month *string) (*model.AIHubMonthlyUsage, error) - } - TemplateAPI interface { ListTemplates(ctx context.Context, skip, limit int) (*model.TemplateConnection, error) ListAllTemplates(ctx context.Context) (model.Templates, error) diff --git a/pkg/model/aihub.go b/pkg/model/aihub.go deleted file mode 100644 index 4f4b680..0000000 --- a/pkg/model/aihub.go +++ /dev/null @@ -1,146 +0,0 @@ -package model - -import ( - "fmt" - "time" -) - -// AIHubTenant represents an AI Hub tenant with balance, keys, and auto-recharge settings. -type AIHubTenant struct { - Balance int `graphql:"balance"` - Keys []AIHubKey `graphql:"keys"` - ProviderCustomerID string `graphql:"providerCustomerID"` - Provider string `graphql:"provider"` - AutoRechargeThreshold int `graphql:"autoRechargeThreshold"` - AutoRechargeAmount int `graphql:"autoRechargeAmount"` -} - -// AIHubKey represents an API key in AI Hub. -type AIHubKey struct { - KeyID string `graphql:"keyID"` - Alias string `graphql:"alias"` - Cost float64 `graphql:"cost"` -} - -// AIHubKeys is a list of AIHubKey for table display. -type AIHubKeys []AIHubKey - -func (k AIHubKeys) Header() []string { - return []string{"Key ID", "Alias", "Cost"} -} - -func (k AIHubKeys) Rows() [][]string { - rows := make([][]string, 0, len(k)) - for _, key := range k { - rows = append(rows, []string{ - key.KeyID, - key.Alias, - fmt.Sprintf("$%.4f", key.Cost), - }) - } - return rows -} - -var _ Tabler = (AIHubKeys)(nil) - -// AddAIHubBalanceResult is the result of adding balance to AI Hub. -type AddAIHubBalanceResult struct { - NewBalance int `graphql:"newBalance"` -} - -// CreateAIHubKeyResult is the result of creating a new AI Hub key. -type CreateAIHubKeyResult struct { - Key AIHubKey `graphql:"key"` - APIKey string `graphql:"apiKey"` -} - -// UpdateAIHubAutoRechargeSettingsResult is the result of updating auto-recharge settings. -type UpdateAIHubAutoRechargeSettingsResult struct { - AutoRechargeThreshold int `graphql:"autoRechargeThreshold"` - AutoRechargeAmount int `graphql:"autoRechargeAmount"` -} - -// AIHubSpendLog represents a single spend log entry. -type AIHubSpendLog struct { - Timestamp time.Time `graphql:"timestamp"` - Cost float64 `graphql:"cost"` - TotalTokens int `graphql:"totalTokens"` - PromptTokens int `graphql:"promptTokens"` - CompletionTokens int `graphql:"completionTokens"` - Model string `graphql:"model"` - KeyAlias string `graphql:"keyAlias"` -} - -// AIHubSpendLogs is a list of AIHubSpendLog for table display. -type AIHubSpendLogs []AIHubSpendLog - -func (l AIHubSpendLogs) Header() []string { - return []string{"Timestamp", "Model", "Key Alias", "Cost", "Total Tokens", "Prompt Tokens", "Completion Tokens"} -} - -func (l AIHubSpendLogs) Rows() [][]string { - rows := make([][]string, 0, len(l)) - for _, log := range l { - rows = append(rows, []string{ - log.Timestamp.Format(time.RFC3339), - log.Model, - log.KeyAlias, - fmt.Sprintf("$%.6f", log.Cost), - fmt.Sprintf("%d", log.TotalTokens), - fmt.Sprintf("%d", log.PromptTokens), - fmt.Sprintf("%d", log.CompletionTokens), - }) - } - return rows -} - -var _ Tabler = (AIHubSpendLogs)(nil) - -// AIHubSpendLogsPaginated is a paginated list of spend logs. -type AIHubSpendLogsPaginated struct { - Data []AIHubSpendLog `graphql:"data"` - Total int `graphql:"total"` - Page int `graphql:"page"` - PageSize int `graphql:"pageSize"` - TotalPages int `graphql:"totalPages"` -} - -// AIHubMonthlyUsage represents the monthly usage summary. -type AIHubMonthlyUsage struct { - TotalSpend float64 `graphql:"totalSpend"` - DailyUsage []AIHubDailyUsage `graphql:"dailyUsage"` - ModelsCost []AIHubModelCost `graphql:"modelsCost"` -} - -// AIHubDailyUsage represents a single day's usage. -type AIHubDailyUsage struct { - Date string `graphql:"date"` - Spend float64 `graphql:"spend"` - Models []AIHubModelCost `graphql:"models"` -} - -// AIHubModelCost represents the cost for a specific model. -type AIHubModelCost struct { - Model string `graphql:"model"` - Cost float64 `graphql:"cost"` -} - -// AIHubModelCosts is a list of AIHubModelCost for table display. -type AIHubModelCosts []AIHubModelCost - -func (m AIHubModelCosts) Header() []string { - return []string{"Model", "Cost"} -} - -func (m AIHubModelCosts) Rows() [][]string { - rows := make([][]string, 0, len(m)) - for _, mc := range m { - rows = append(rows, []string{ - mc.Model, - fmt.Sprintf("$%.6f", mc.Cost), - }) - } - return rows -} - -var _ Tabler = (AIHubModelCosts)(nil)