From 0300b6e844f1df228d75b11e231f3ca3f3fcd9fb Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Tue, 7 Jul 2026 15:18:05 -0500 Subject: [PATCH 1/9] Add structured JSON next_steps to workflow commands and a global JSON error envelope. Workflow commands (create, cutover, cancel, switch-traffic, etc.) now return status/issues/next_steps JSON with contextual remediation commands in --format json mode. Unhandled errors on any command now fall back to a classified {status, code, error, next_steps} envelope on stdout instead of a bare {"error"} blob on stderr. Co-authored-by: Cursor --- internal/cmd/root.go | 59 ++------ internal/cmd/workflow/cancel.go | 17 ++- internal/cmd/workflow/complete.go | 11 +- internal/cmd/workflow/create.go | 28 +++- internal/cmd/workflow/cutover.go | 23 ++-- internal/cmd/workflow/json.go | 107 +++++++++++++++ internal/cmd/workflow/json_test.go | 101 ++++++++++++++ internal/cmd/workflow/list.go | 9 +- internal/cmd/workflow/retry.go | 11 +- internal/cmd/workflow/reverse_cutover.go | 11 +- internal/cmd/workflow/reverse_traffic.go | 11 +- internal/cmd/workflow/show.go | 11 +- internal/cmd/workflow/switch_traffic.go | 21 ++- internal/cmd/workflow/verify_data.go | 11 +- internal/cmdutil/agent_steps.go | 81 +++++++---- internal/cmdutil/agent_steps_test.go | 6 +- internal/cmdutil/json_error.go | 168 +++++++++++++++++++++++ internal/cmdutil/json_error_test.go | 164 ++++++++++++++++++++++ 18 files changed, 710 insertions(+), 140 deletions(-) create mode 100644 internal/cmd/workflow/json.go create mode 100644 internal/cmd/workflow/json_test.go create mode 100644 internal/cmdutil/json_error.go create mode 100644 internal/cmdutil/json_error_test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 682aaa4a2..36fd2304d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -16,9 +16,7 @@ limitations under the License. package cmd import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "io/fs" @@ -127,28 +125,26 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver, return 0 } - var cmdErr *cmdutil.Error - if errors.As(err, &cmdErr) && cmdErr.Handled { + if cmdErr, ok := errors.AsType[*cmdutil.Error](err); ok && cmdErr.Handled { if cmdErr.ExitCode != 0 { return cmdErr.ExitCode } return cmdutil.ActionRequestedExitCode } - if isRootOrgFlagError(err) { - return printRootOrgFlagError(format, err) + if format == printer.JSON { + return cmdutil.ReportGlobalJSONError(os.Stdout, os.Stderr, err) } - // print any user specific messages first - switch format { - case printer.JSON: - fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err) - default: - fmt.Fprintf(os.Stderr, "Error: %s\n", err) + if isRootOrgFlagError(err) { + fmt.Fprintf(os.Stderr, "Error: %s\nHint: --org belongs on resource commands, not on pscale root. Example: pscale database list --org --format json\n", err) + return cmdutil.FatalErrExitCode } + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + // check if a sub command wants to return a specific exit code - if errors.As(err, &cmdErr) { + if cmdErr, ok := errors.AsType[*cmdutil.Error](err); ok { return cmdErr.ExitCode } @@ -162,43 +158,6 @@ func isRootOrgFlagError(err error) bool { return strings.Contains(err.Error(), "unknown flag: --org") } -func printRootOrgFlagError(format printer.Format, err error) int { - const hint = "--org belongs on resource commands, not on pscale root. Example: pscale database list --org --format json" - - if format == printer.JSON { - resp := struct { - Status string `json:"status"` - Code string `json:"code"` - Error string `json:"error"` - Hint string `json:"hint"` - NextSteps []string `json:"next_steps"` - }{ - Status: "error", - Code: "INVALID_FLAG_PLACEMENT", - Error: err.Error(), - Hint: hint, - NextSteps: []string{ - cmdutil.AgentGuideCmd(), - cmdutil.AgentAuthCheckCmd(), - cmdutil.AgentDatabaseListCmd(""), - }, - } - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - if marshalErr := enc.Encode(resp); marshalErr != nil { - fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err) - return cmdutil.FatalErrExitCode - } - fmt.Fprint(os.Stdout, buf.String()) - return cmdutil.FatalErrExitCode - } - - fmt.Fprintf(os.Stderr, "Error: %s\nHint: %s\n", err, hint) - return cmdutil.FatalErrExitCode -} - // runCmd adds all child commands to the root command, sets flags // appropriately, and runs the root command. func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer.Format, debug *bool, sigc chan os.Signal, signals []os.Signal) error { diff --git a/internal/cmd/workflow/cancel.go b/internal/cmd/workflow/cancel.go index b91ebf52c..d94fc4777 100644 --- a/internal/cmd/workflow/cancel.go +++ b/internal/cmd/workflow/cancel.go @@ -26,21 +26,24 @@ marks it as cancelled, allowing you to start a new workflow if needed.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } if !force { - if ch.Printer.Format() != printer.Human { - return fmt.Errorf(`cannot cancel workflow with the output format "%s" (run with --force to override)`, ch.Printer.Format()) + if ch.Printer.Format() == printer.JSON { + return wfCtx.reportConfirmationRequired(ch, "CANCEL_CONFIRMATION_REQUIRED", + "Cancel stops the workflow and marks it as cancelled", + cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cancel", "--force")) } if !printer.IsTTY { @@ -78,10 +81,10 @@ marks it as cancelled, allowing you to start a new workflow if needed.`, if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/complete.go b/internal/cmd/workflow/complete.go index 391f87b46..aa1a1c890 100644 --- a/internal/cmd/workflow/complete.go +++ b/internal/cmd/workflow/complete.go @@ -19,16 +19,17 @@ func CompleteCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Marking workflow %s in database %s as complete…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db))) @@ -42,10 +43,10 @@ func CompleteCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/create.go b/internal/cmd/workflow/create.go index 4e3731305..d35fdad02 100644 --- a/internal/cmd/workflow/create.go +++ b/internal/cmd/workflow/create.go @@ -34,18 +34,21 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, branch := args[0], args[1] + org := ch.Config.Organization client, err := ch.Client() if err != nil { - return err + return errorContext{Org: org, Database: db, Branch: branch}.handle(ch, err) } - org := ch.Config.Organization - if flags.interactive { return createInteractive(ctx, ch, org, db, branch, flags) } + if err := validateCreateFlags(flags); err != nil { + return reportMissingCreateFlags(ch, org, db, branch, err) + } + end := ch.Printer.PrintProgress("Creating workflow…") defer end() @@ -53,7 +56,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { end() if err != nil { - return err + return errorContext{Org: org, Database: db, Branch: branch}.handle(ch, err) } if ch.Printer.Format() == printer.Human { @@ -111,6 +114,23 @@ func createWorkflow(ctx context.Context, client *ps.Client, org, db, branch stri return workflow, nil } +func validateCreateFlags(flags createFlags) error { + var missing []string + if flags.sourceKeyspace == "" { + missing = append(missing, "--source-keyspace") + } + if flags.targetKeyspace == "" { + missing = append(missing, "--target-keyspace") + } + if len(flags.tables) == 0 { + missing = append(missing, "--tables") + } + if len(missing) == 0 { + return nil + } + return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", ")) +} + func createInteractive(ctx context.Context, ch *cmdutil.Helper, org, db, branch string, flags createFlags) error { client, err := ch.Client() if err != nil { diff --git a/internal/cmd/workflow/cutover.go b/internal/cmd/workflow/cutover.go index 5609030f7..16ea52f07 100644 --- a/internal/cmd/workflow/cutover.go +++ b/internal/cmd/workflow/cutover.go @@ -25,21 +25,24 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } if !force { - if ch.Printer.Format() != printer.Human { - return fmt.Errorf(`cannot cutover with the output format "%s" (run with --force to override)`, ch.Printer.Format()) + if ch.Printer.Format() == printer.JSON { + return wfCtx.reportConfirmationRequired(ch, "CUTOVER_CONFIRMATION_REQUIRED", + "Cutover deletes moved tables from the source keyspace and ends replication", + cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cutover", "--force")) } if !printer.IsTTY { @@ -54,10 +57,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } @@ -93,10 +96,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/json.go b/internal/cmd/workflow/json.go new file mode 100644 index 000000000..29bc32035 --- /dev/null +++ b/internal/cmd/workflow/json.go @@ -0,0 +1,107 @@ +package workflow + +import ( + "strings" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" + ps "github.com/planetscale/planetscale-go/planetscale" +) + +// errorContext carries workflow command context so JSON error responses can +// include remediation commands with real values instead of placeholders. +type errorContext struct { + Org string + Database string + Branch string + Number string +} + +// handle converts err into a structured JSON error when the printer is in +// JSON mode; in human mode it returns err unchanged. +func (c errorContext) handle(ch *cmdutil.Helper, err error) error { + if err == nil || ch.Printer.Format() != printer.JSON { + return err + } + if cmdutil.ErrCode(err) == ps.ErrNotFound || strings.Contains(err.Error(), "does not exist") { + return c.report(ch, "error", "NOT_FOUND", err.Error(), c.notFoundNextSteps()) + } + return c.report(ch, "error", "WORKFLOW_ERROR", err.Error(), c.defaultNextSteps()) +} + +// reportConfirmationRequired reports a confirmation-gated action in JSON mode. +// Callers gate on JSON format before calling. +func (c errorContext) reportConfirmationRequired(ch *cmdutil.Helper, code, message, retryCmd string) error { + return c.report(ch, "action_required", code, message, []string{ + "Ask the user to approve this workflow action", + retryCmd, + }) +} + +func reportMissingCreateFlags(ch *cmdutil.Helper, org, database, branch string, err error) error { + if ch.Printer.Format() != printer.JSON { + return err + } + c := errorContext{Org: org, Database: database, Branch: branch} + return c.report(ch, "action_required", "MISSING_FLAGS", err.Error(), []string{ + cmdutil.AgentKeyspaceListCmd(org, database, branch), + cmdutil.AgentWorkflowCreateCmd(org, database, branch), + }) +} + +func (c errorContext) report(ch *cmdutil.Helper, status, code, message string, nextSteps []string) error { + payload := map[string]any{ + "status": status, + "error": message, + "issues": []map[string]string{ + { + "code": code, + "message": message, + }, + }, + "next_steps": nextSteps, + } + if c.Database != "" { + payload["database"] = c.Database + } + if c.Branch != "" { + payload["branch"] = c.Branch + } + if c.Number != "" { + payload["workflow_number"] = c.Number + } + + if err := ch.Printer.PrintJSON(payload); err != nil { + return err + } + exitCode := cmdutil.FatalErrExitCode + if status == "action_required" { + exitCode = cmdutil.ActionRequestedExitCode + } + return cmdutil.JSONReportedError(exitCode) +} + +func (c errorContext) notFoundNextSteps() []string { + steps := []string{ + cmdutil.AgentDatabaseListCmd(c.Org), + cmdutil.AgentWorkflowListCmd(c.Org, c.Database), + } + if c.Branch != "" { + steps = append(steps, cmdutil.AgentKeyspaceListCmd(c.Org, c.Database, c.Branch)) + } + if c.Number != "" { + steps = append(steps, cmdutil.AgentWorkflowShowCmd(c.Org, c.Database, c.Number)) + } + return steps +} + +func (c errorContext) defaultNextSteps() []string { + steps := []string{cmdutil.AgentAuthCheckCmd()} + if c.Database != "" { + steps = append(steps, cmdutil.AgentWorkflowListCmd(c.Org, c.Database)) + if c.Number != "" { + steps = append(steps, cmdutil.AgentWorkflowShowCmd(c.Org, c.Database, c.Number)) + } + } + return steps +} diff --git a/internal/cmd/workflow/json_test.go b/internal/cmd/workflow/json_test.go new file mode 100644 index 000000000..4437d1d85 --- /dev/null +++ b/internal/cmd/workflow/json_test.go @@ -0,0 +1,101 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" +) + +func TestHandleWorkflowErrorMissingFlagsJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + err := reportMissingCreateFlags(ch, "bb", "mydb", "main", errors.New("missing required flags: --source-keyspace, --tables")) + if err == nil { + t.Fatal("expected handled JSON error") + } + if cmdErr, ok := errors.AsType[*cmdutil.Error](err); !ok || !cmdErr.Handled { + t.Fatalf("expected handled JSON error, got %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp["status"] != "action_required" { + t.Fatalf("status = %v", resp["status"]) + } + nextSteps, ok := resp["next_steps"].([]any) + if !ok || len(nextSteps) < 2 { + t.Fatalf("next_steps = %v", resp["next_steps"]) + } +} + +func TestHandleWorkflowErrorNotFoundJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + ctx := errorContext{Org: "bb", Database: "mydb", Branch: "main"} + err := ctx.handle(ch, errors.New("database mydb does not exist in organization bb")) + if err == nil { + t.Fatal("expected handled JSON error") + } + + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp["status"] != "error" { + t.Fatalf("status = %v", resp["status"]) + } +} + +func TestReportConfirmationRequiredJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + ctx := errorContext{Org: "bb", Database: "mydb", Number: "1"} + err := ctx.reportConfirmationRequired(ch, "CUTOVER_CONFIRMATION_REQUIRED", + "Cutover deletes moved tables from the source keyspace and ends replication", + cmdutil.AgentWorkflowActionCmd("bb", "mydb", "1", "cutover", "--force")) + if err == nil { + t.Fatal("expected handled JSON error") + } + + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp["status"] != "action_required" { + t.Fatalf("status = %v", resp["status"]) + } +} + +func TestHandleWorkflowErrorHumanModePassthrough(t *testing.T) { + format := printer.Human + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + + orig := errors.New("database mydb does not exist in organization bb") + err := errorContext{Org: "bb", Database: "mydb"}.handle(ch, orig) + if !errors.Is(err, orig) { + t.Fatalf("expected original error, got %v", err) + } +} diff --git a/internal/cmd/workflow/list.go b/internal/cmd/workflow/list.go index bf6ec52e9..43b854176 100644 --- a/internal/cmd/workflow/list.go +++ b/internal/cmd/workflow/list.go @@ -22,10 +22,11 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db := args[0] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching workflows for database %s…", printer.BoldBlue(db))) @@ -38,10 +39,10 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/retry.go b/internal/cmd/workflow/retry.go index 8e41b34bd..b6ab38aea 100644 --- a/internal/cmd/workflow/retry.go +++ b/internal/cmd/workflow/retry.go @@ -19,16 +19,17 @@ func RetryCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Retrying workflow %s in database %s…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db))) @@ -42,10 +43,10 @@ func RetryCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/reverse_cutover.go b/internal/cmd/workflow/reverse_cutover.go index 61b22387c..15091c339 100644 --- a/internal/cmd/workflow/reverse_cutover.go +++ b/internal/cmd/workflow/reverse_cutover.go @@ -20,16 +20,17 @@ This is useful if your application has errors and you need to rollback after a c RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Reversing cutover for workflow %s in database %s…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db))) @@ -43,10 +44,10 @@ This is useful if your application has errors and you need to rollback after a c if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } diff --git a/internal/cmd/workflow/reverse_traffic.go b/internal/cmd/workflow/reverse_traffic.go index d7eb0aba7..ff042fae5 100644 --- a/internal/cmd/workflow/reverse_traffic.go +++ b/internal/cmd/workflow/reverse_traffic.go @@ -20,16 +20,17 @@ This is useful when you need to rollback a traffic switch operation.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Reversing traffic for workflow %s in database %s…", printer.BoldBlue(number), printer.BoldBlue(db))) @@ -43,10 +44,10 @@ This is useful when you need to rollback a traffic switch operation.`, if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/show.go b/internal/cmd/workflow/show.go index 196ef9347..e8889b2e3 100644 --- a/internal/cmd/workflow/show.go +++ b/internal/cmd/workflow/show.go @@ -20,16 +20,17 @@ func ShowCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching workflow %s for database %s…", printer.BoldBlue(number), printer.BoldBlue(db))) @@ -43,10 +44,10 @@ func ShowCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/switch_traffic.go b/internal/cmd/workflow/switch_traffic.go index 69301afb7..14808fe67 100644 --- a/internal/cmd/workflow/switch_traffic.go +++ b/internal/cmd/workflow/switch_traffic.go @@ -27,24 +27,31 @@ By default, this command will route all queries for primary, replica, and read-o RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } var workflow *ps.Workflow var end func() if !force { - if ch.Printer.Format() != printer.Human { - return fmt.Errorf(`cannot switch query traffic with the output format "%s" (run with --force to override)`, ch.Printer.Format()) + if ch.Printer.Format() == printer.JSON { + retryFlags := []string{"--force"} + if replicasOnly { + retryFlags = []string{"--replicas-only", "--force"} + } + return wfCtx.reportConfirmationRequired(ch, "SWITCH_TRAFFIC_CONFIRMATION_REQUIRED", + "Switch traffic routes queries to the target keyspace for this workflow", + cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "switch-traffic", retryFlags...)) } if !printer.IsTTY { @@ -97,10 +104,10 @@ By default, this command will route all queries for primary, replica, and read-o if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmd/workflow/verify_data.go b/internal/cmd/workflow/verify_data.go index 2ed2e152d..a47e21459 100644 --- a/internal/cmd/workflow/verify_data.go +++ b/internal/cmd/workflow/verify_data.go @@ -18,16 +18,17 @@ func VerifyDataCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] + wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return err + return wfCtx.handle(ch, err) } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return err + return wfCtx.handle(ch, err) } end := ch.Printer.PrintProgress(fmt.Sprintf("Verifying data for workflow %s in database %s…", printer.BoldBlue(number), printer.BoldBlue(db))) @@ -41,10 +42,10 @@ func VerifyDataCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) + return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) default: - return cmdutil.HandleError(err) + return wfCtx.handle(ch, cmdutil.HandleError(err)) } } end() diff --git a/internal/cmdutil/agent_steps.go b/internal/cmdutil/agent_steps.go index 0058ef9a8..cdc67b6f5 100644 --- a/internal/cmdutil/agent_steps.go +++ b/internal/cmdutil/agent_steps.go @@ -1,8 +1,12 @@ package cmdutil -import "fmt" +import ( + "fmt" + "strings" +) -// Agent command strings for next_steps — flags go after the subcommand (not on pscale root). +// Agent command strings for next_steps — flags go after the subcommand (not on +// pscale root). Empty values render as for the agent to fill in. func AgentGuideCmd() string { return "pscale agent-guide --format json" @@ -21,37 +25,62 @@ func AgentOrgListCmd() string { } func AgentDatabaseListCmd(org string) string { - if org == "" { - return "pscale database list --org --format json" - } - return fmt.Sprintf("pscale database list --org %s --format json", org) + return fmt.Sprintf("pscale database list --org %s --format json", orPlaceholder(org, "")) } func AgentBranchListCmd(org, database string) string { - if database == "" { - database = "" - } - if org == "" { - return fmt.Sprintf("pscale branch list %s --org --format json", database) - } - return fmt.Sprintf("pscale branch list %s --org %s --format json", database, org) + return fmt.Sprintf("pscale branch list %s --org %s --format json", + orPlaceholder(database, ""), orPlaceholder(org, "")) } func AgentSQLCmd(org, database, branch string, force bool) string { - if database == "" { - database = "" - } - if branch == "" { - branch = "" - } - query := "SELECT 1" - forceFlag := "" + forceFlag, query := "", "SELECT 1" if force { - forceFlag = " --force" - query = "" + forceFlag, query = " --force", "" + } + return fmt.Sprintf("pscale sql %s %s --org %s --format json%s --query %q", + orPlaceholder(database, ""), orPlaceholder(branch, ""), + orPlaceholder(org, ""), forceFlag, query) +} + +func AgentWorkflowListCmd(org, database string) string { + return fmt.Sprintf("pscale workflow list %s --org %s --format json", + orPlaceholder(database, ""), orPlaceholder(org, "")) +} + +func AgentWorkflowShowCmd(org, database, number string) string { + return fmt.Sprintf("pscale workflow show %s %s --org %s --format json", + orPlaceholder(database, ""), orPlaceholder(number, ""), + orPlaceholder(org, "")) +} + +func AgentWorkflowCreateCmd(org, database, branch string) string { + return fmt.Sprintf("pscale workflow create %s %s --org %s --format json --source-keyspace --target-keyspace --tables ", + orPlaceholder(database, ""), orPlaceholder(branch, ""), + orPlaceholder(org, "")) +} + +func AgentKeyspaceListCmd(org, database, branch string) string { + return fmt.Sprintf("pscale keyspace list %s %s --org %s --format json", + orPlaceholder(database, ""), orPlaceholder(branch, ""), + orPlaceholder(org, "")) +} + +// AgentWorkflowActionCmd renders a workflow subcommand like cutover or cancel, +// with extraFlags appended after --format json. +func AgentWorkflowActionCmd(org, database, number, action string, extraFlags ...string) string { + flags := "" + if len(extraFlags) > 0 { + flags = " " + strings.Join(extraFlags, " ") } - if org == "" { - return fmt.Sprintf("pscale sql %s %s --org --format json%s --query \"%s\"", database, branch, forceFlag, query) + return fmt.Sprintf("pscale workflow %s %s %s --org %s --format json%s", + action, orPlaceholder(database, ""), orPlaceholder(number, ""), + orPlaceholder(org, ""), flags) +} + +func orPlaceholder(s, placeholder string) string { + if s == "" { + return placeholder } - return fmt.Sprintf("pscale sql %s %s --org %s --format json%s --query \"%s\"", database, branch, org, forceFlag, query) + return s } diff --git a/internal/cmdutil/agent_steps_test.go b/internal/cmdutil/agent_steps_test.go index f68812374..67e3370f6 100644 --- a/internal/cmdutil/agent_steps_test.go +++ b/internal/cmdutil/agent_steps_test.go @@ -16,6 +16,9 @@ func TestAgentStepsFlagOrder(t *testing.T) { {name: "database list", got: AgentDatabaseListCmd("bb")}, {name: "branch list", got: AgentBranchListCmd("bb", "mydb")}, {name: "sql", got: AgentSQLCmd("bb", "mydb", "main", false)}, + {name: "workflow list", got: AgentWorkflowListCmd("bb", "mydb")}, + {name: "workflow create", got: AgentWorkflowCreateCmd("bb", "mydb", "main")}, + {name: "workflow cutover", got: AgentWorkflowActionCmd("bb", "mydb", "1", "cutover", "--force")}, } for _, tt := range tests { @@ -41,8 +44,7 @@ func TestCheckAuthenticationJSONHandledError(t *testing.T) { if err == nil { t.Fatal("expected auth error") } - var cmdErr *Error - if !errors.As(err, &cmdErr) || !cmdErr.Handled { + if cmdErr, ok := errors.AsType[*Error](err); !ok || !cmdErr.Handled { t.Fatalf("expected handled JSON error, got %v", err) } } diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go new file mode 100644 index 000000000..ea26628b3 --- /dev/null +++ b/internal/cmdutil/json_error.go @@ -0,0 +1,168 @@ +package cmdutil + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" +) + +// JSONErrorResponse is the fallback JSON envelope for unhandled command errors. +type JSONErrorResponse struct { + Status string `json:"status"` + Code string `json:"code,omitempty"` + Error string `json:"error"` + NextSteps []string `json:"next_steps"` +} + +// GlobalJSONError classifies an error for the global JSON envelope. +// +// Classification is by message text because this is the top-level fallback: +// command-specific handlers with typed errors have already had their chance. +// next_steps must earn their place: each case lists only the commands that +// actually address the failure, not generic bootstrap commands. +func GlobalJSONError(err error) JSONErrorResponse { + msg := err.Error() + status := "error" + code := "COMMAND_FAILED" + + if cmdErr, ok := errors.AsType[*Error](err); ok { + if cmdErr.Msg != "" { + msg = cmdErr.Msg + } + if cmdErr.ExitCode == ActionRequestedExitCode { + status = "action_required" + } + } + + var nextSteps []string + lower := strings.ToLower(msg) + + switch { + // Auth problems: the fix is logging in (or fixing credentials), then verifying. + case strings.Contains(msg, WarnAuthMessage) || + strings.Contains(lower, "not authenticated") || + strings.Contains(msg, "access token has expired"): + status = "action_required" + code = "NO_AUTH" + nextSteps = []string{AgentAuthLoginCmd(), AgentAuthCheckCmd()} + + case strings.Contains(msg, "Authentication failed") && strings.Contains(msg, "service token"): + status = "action_required" + code = "SERVICE_TOKEN_INVALID" + nextSteps = []string{ + "Verify --service-token-id and --service-token values (or PLANETSCALE_SERVICE_TOKEN_ID / PLANETSCALE_SERVICE_TOKEN)", + AgentAuthCheckCmd(), + } + + // --org on pscale root: show the corrected shape, not a re-auth loop. + case strings.Contains(msg, "unknown flag: --org"): + code = "INVALID_FLAG_PLACEMENT" + nextSteps = []string{ + "Move --org onto the resource subcommand", + AgentDatabaseListCmd(""), + } + + // Wrong invocation: cobra's message already names the missing piece and + // includes usage. Point at the guide for command shapes; auth is not the issue. + case strings.Contains(msg, "missing argument") || + strings.Contains(msg, "missing arguments") || + strings.Contains(msg, "missing required flags") || + strings.Contains(msg, "required flag"): + status = "action_required" + code = "INVALID_USAGE" + nextSteps = []string{ + "Re-run with the missing arguments or flags named in the error message", + AgentGuideCmd(), + } + + case strings.Contains(msg, "unknown command"): + code = "UNKNOWN_COMMAND" + nextSteps = []string{ + "Run `pscale --help` to list valid commands", + AgentGuideCmd(), + } + + case strings.Contains(msg, "unknown flag") || strings.Contains(msg, "unknown shorthand flag"): + code = "UNKNOWN_FLAG" + nextSteps = []string{ + "Run the same command with --help to list valid flags", + AgentGuideCmd(), + } + + // TTY-gated commands: JSON mode is the non-interactive path. + case strings.Contains(msg, "requires an interactive shell"): + status = "action_required" + code = "TTY_REQUIRED" + nextSteps = []string{AgentAuthLoginCmd()} + + // Confirmation-gated commands: the fix is user approval, then --force. + case strings.Contains(msg, "run with --force"): + status = "action_required" + code = "CONFIRMATION_REQUIRED" + nextSteps = []string{ + "Ask the user to approve this action", + "Re-run the same command with --force after approval", + } + + // Resource lookups that failed: discovery commands are the way forward. + case strings.Contains(msg, "does not exist"): + code = "NOT_FOUND" + nextSteps = []string{ + AgentOrgListCmd(), + AgentDatabaseListCmd(""), + } + + // Connectivity: nothing an agent can self-serve beyond retry and status. + case strings.Contains(lower, "connection refused") || + strings.Contains(lower, "no such host") || + strings.Contains(lower, "timeout") || + strings.Contains(lower, "temporary failure"): + code = "NETWORK_ERROR" + nextSteps = []string{ + "Check network connectivity and --api-url, then retry", + AgentAuthCheckCmd(), + } + + // Unclassified: auth state is the one thing worth ruling out first. + default: + nextSteps = []string{ + AgentAuthCheckCmd(), + AgentGuideCmd(), + } + } + + return JSONErrorResponse{ + Status: status, + Code: code, + Error: msg, + NextSteps: nextSteps, + } +} + +// ReportGlobalJSONError writes the classified envelope for err to w and +// returns the process exit code. On encoding failure it falls back to a +// plain error line on errW. +func ReportGlobalJSONError(w, errW io.Writer, err error) int { + resp := GlobalJSONError(err) + if writeErr := WriteJSONError(w, resp); writeErr != nil { + fmt.Fprintf(errW, "Error: %s\n", err) + } + + if resp.Status == "action_required" { + return ActionRequestedExitCode + } + if cmdErr, ok := errors.AsType[*Error](err); ok && cmdErr.ExitCode != 0 { + return cmdErr.ExitCode + } + return FatalErrExitCode +} + +// WriteJSONError writes a JSON error envelope to w. +func WriteJSONError(w io.Writer, resp JSONErrorResponse) error { + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + return enc.Encode(resp) +} diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go new file mode 100644 index 000000000..001643a4e --- /dev/null +++ b/internal/cmdutil/json_error_test.go @@ -0,0 +1,164 @@ +package cmdutil + +import ( + "bytes" + "encoding/json" + "errors" + "testing" +) + +func TestGlobalJSONErrorAuthRequired(t *testing.T) { + resp := GlobalJSONError(errors.New("not authenticated yet. Please run 'pscale auth login'")) + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code != "NO_AUTH" { + t.Fatalf("code = %q", resp.Code) + } + if len(resp.NextSteps) == 0 || resp.NextSteps[0] != AgentAuthLoginCmd() { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } +} + +func TestGlobalJSONErrorInvalidUsageSkipsAuth(t *testing.T) { + resp := GlobalJSONError(errors.New("missing argument \n\nUsage: ...")) + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code != "INVALID_USAGE" { + t.Fatalf("code = %q", resp.Code) + } + for _, step := range resp.NextSteps { + if step == AgentAuthCheckCmd() || step == AgentAuthLoginCmd() { + t.Fatalf("invalid usage should not suggest auth commands, got %#v", resp.NextSteps) + } + } +} + +func TestGlobalJSONErrorOrgFlagPlacement(t *testing.T) { + resp := GlobalJSONError(errors.New("unknown flag: --org")) + if resp.Code != "INVALID_FLAG_PLACEMENT" { + t.Fatalf("code = %q", resp.Code) + } + if len(resp.NextSteps) != 2 || resp.NextSteps[1] != AgentDatabaseListCmd("") { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } +} + +func TestGlobalJSONErrorUnknownFlag(t *testing.T) { + resp := GlobalJSONError(errors.New("unknown flag: --bogus")) + if resp.Code != "UNKNOWN_FLAG" { + t.Fatalf("code = %q", resp.Code) + } +} + +func TestGlobalJSONErrorUnknownCommand(t *testing.T) { + resp := GlobalJSONError(errors.New(`unknown command "wat" for "pscale"`)) + if resp.Code != "UNKNOWN_COMMAND" { + t.Fatalf("code = %q", resp.Code) + } + for _, step := range resp.NextSteps { + if step == AgentAuthCheckCmd() { + t.Fatalf("unknown command should not suggest auth check, got %#v", resp.NextSteps) + } + } +} + +func TestGlobalJSONErrorConfirmationRequired(t *testing.T) { + resp := GlobalJSONError(errors.New("cannot confirm deletion of database (run with --force to override)")) + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q", resp.Code) + } + if len(resp.NextSteps) != 2 { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } +} + +func TestGlobalJSONErrorNotFound(t *testing.T) { + resp := GlobalJSONError(errors.New("database foo does not exist in organization bar")) + if resp.Code != "NOT_FOUND" { + t.Fatalf("code = %q", resp.Code) + } + if len(resp.NextSteps) == 0 || resp.NextSteps[0] != AgentOrgListCmd() { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } +} + +func TestGlobalJSONErrorNetwork(t *testing.T) { + resp := GlobalJSONError(errors.New("dial tcp: lookup api.planetscale.com: no such host")) + if resp.Code != "NETWORK_ERROR" { + t.Fatalf("code = %q", resp.Code) + } +} + +func TestGlobalJSONErrorServiceToken(t *testing.T) { + resp := GlobalJSONError(errors.New("Authentication failed. Your service token appears to be invalid.")) + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code != "SERVICE_TOKEN_INVALID" { + t.Fatalf("code = %q", resp.Code) + } +} + +func TestGlobalJSONErrorGenericFallback(t *testing.T) { + resp := GlobalJSONError(errors.New("something went wrong")) + if resp.Status != "error" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code != "COMMAND_FAILED" { + t.Fatalf("code = %q", resp.Code) + } + if len(resp.NextSteps) != 2 || resp.NextSteps[0] != AgentAuthCheckCmd() { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } +} + +func TestGlobalJSONErrorUsesCmdErrorMessage(t *testing.T) { + resp := GlobalJSONError(&Error{ + Msg: "You are not authenticated.", + ExitCode: ActionRequestedExitCode, + }) + if resp.Error != "You are not authenticated." { + t.Fatalf("error = %q", resp.Error) + } + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } +} + +func TestReportGlobalJSONError(t *testing.T) { + var out, errOut bytes.Buffer + exit := ReportGlobalJSONError(&out, &errOut, errors.New("boom")) + if exit != FatalErrExitCode { + t.Fatalf("exit = %d", exit) + } + if errOut.Len() != 0 { + t.Fatalf("unexpected stderr: %q", errOut.String()) + } + + var resp JSONErrorResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp.Status != "error" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Error == "" || len(resp.NextSteps) == 0 { + t.Fatalf("resp = %#v", resp) + } +} + +func TestReportGlobalJSONErrorActionRequiredExitCode(t *testing.T) { + var out, errOut bytes.Buffer + exit := ReportGlobalJSONError(&out, &errOut, &Error{ + Msg: "needs action", + ExitCode: ActionRequestedExitCode, + }) + if exit != ActionRequestedExitCode { + t.Fatalf("exit = %d", exit) + } +} From 86989ca474a6b58989aad14875ad375ebd6ee7a8 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Tue, 7 Jul 2026 15:46:20 -0500 Subject: [PATCH 2/9] Defer non-workflow errors to the global JSON classifier in workflow commands. Auth failures (expired token, not authenticated) surfaced through workflow subcommands now report NO_AUTH with login next_steps instead of a generic WORKFLOW_ERROR, matching the remediation agents get on any other command. Co-authored-by: Cursor --- internal/cmd/workflow/json.go | 7 ++++++ internal/cmd/workflow/json_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/cmd/workflow/json.go b/internal/cmd/workflow/json.go index 29bc32035..0856ddd88 100644 --- a/internal/cmd/workflow/json.go +++ b/internal/cmd/workflow/json.go @@ -26,6 +26,13 @@ func (c errorContext) handle(ch *cmdutil.Helper, err error) error { if cmdutil.ErrCode(err) == ps.ErrNotFound || strings.Contains(err.Error(), "does not exist") { return c.report(ch, "error", "NOT_FOUND", err.Error(), c.notFoundNextSteps()) } + // Failures that are not workflow-specific (auth, network, usage) get the + // same code and remediation the global classifier would give on any other + // command, so an expired token reports NO_AUTH with login steps rather + // than WORKFLOW_ERROR. + if resp := cmdutil.GlobalJSONError(err); resp.Code != "COMMAND_FAILED" { + return c.report(ch, resp.Status, resp.Code, resp.Error, resp.NextSteps) + } return c.report(ch, "error", "WORKFLOW_ERROR", err.Error(), c.defaultNextSteps()) } diff --git a/internal/cmd/workflow/json_test.go b/internal/cmd/workflow/json_test.go index 4437d1d85..d3a79605a 100644 --- a/internal/cmd/workflow/json_test.go +++ b/internal/cmd/workflow/json_test.go @@ -87,6 +87,44 @@ func TestReportConfirmationRequiredJSON(t *testing.T) { } } +func TestHandleWorkflowErrorAuthFailureJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + ctx := errorContext{Org: "bb", Database: "mydb", Number: "1"} + err := ctx.handle(ch, errors.New("the access token has expired. Please run 'pscale auth login'")) + if err == nil { + t.Fatal("expected handled JSON error") + } + if cmdErr, ok := errors.AsType[*cmdutil.Error](err); !ok || !cmdErr.Handled || cmdErr.ExitCode != cmdutil.ActionRequestedExitCode { + t.Fatalf("expected handled action_required error, got %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp["status"] != "action_required" { + t.Fatalf("status = %v", resp["status"]) + } + issues, ok := resp["issues"].([]any) + if !ok || len(issues) == 0 { + t.Fatalf("issues = %v", resp["issues"]) + } + issue, ok := issues[0].(map[string]any) + if !ok || issue["code"] != "NO_AUTH" { + t.Fatalf("issue = %v", issues[0]) + } + nextSteps, ok := resp["next_steps"].([]any) + if !ok || len(nextSteps) == 0 || nextSteps[0] != cmdutil.AgentAuthLoginCmd() { + t.Fatalf("next_steps = %v", resp["next_steps"]) + } +} + func TestHandleWorkflowErrorHumanModePassthrough(t *testing.T) { format := printer.Human ch := &cmdutil.Helper{ From 2cac4d562433522379cd97abd9f6036643f56359 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Tue, 7 Jul 2026 15:54:22 -0500 Subject: [PATCH 3/9] Only classify transport-level failures as NETWORK_ERROR A bare "timeout" substring match swallowed operation and deadline timeouts (e.g. workflow cutover timeouts), giving agents connectivity remediation instead of command-specific next_steps. Match specific transport error strings (i/o timeout, TLS handshake timeout, connection refused/reset, no such host) instead. Co-authored-by: Cursor --- internal/cmdutil/json_error.go | 9 +++++++-- internal/cmdutil/json_error_test.go | 29 ++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go index ea26628b3..9d4532412 100644 --- a/internal/cmdutil/json_error.go +++ b/internal/cmdutil/json_error.go @@ -115,10 +115,15 @@ func GlobalJSONError(err error) JSONErrorResponse { } // Connectivity: nothing an agent can self-serve beyond retry and status. + // Match transport-level failures only — a bare "timeout" substring would + // swallow operation or deadline timeouts that deserve command-specific + // handling. case strings.Contains(lower, "connection refused") || + strings.Contains(lower, "connection reset by peer") || strings.Contains(lower, "no such host") || - strings.Contains(lower, "timeout") || - strings.Contains(lower, "temporary failure"): + strings.Contains(lower, "i/o timeout") || + strings.Contains(lower, "tls handshake timeout") || + strings.Contains(lower, "temporary failure in name resolution"): code = "NETWORK_ERROR" nextSteps = []string{ "Check network connectivity and --api-url, then retry", diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go index 001643a4e..07529af12 100644 --- a/internal/cmdutil/json_error_test.go +++ b/internal/cmdutil/json_error_test.go @@ -88,9 +88,32 @@ func TestGlobalJSONErrorNotFound(t *testing.T) { } func TestGlobalJSONErrorNetwork(t *testing.T) { - resp := GlobalJSONError(errors.New("dial tcp: lookup api.planetscale.com: no such host")) - if resp.Code != "NETWORK_ERROR" { - t.Fatalf("code = %q", resp.Code) + networkErrors := []string{ + "dial tcp: lookup api.planetscale.com: no such host", + "dial tcp 10.0.0.1:443: i/o timeout", + "dial tcp 10.0.0.1:443: connect: connection refused", + "read tcp 10.0.0.1:443: connection reset by peer", + "net/http: TLS handshake timeout", + } + for _, msg := range networkErrors { + if resp := GlobalJSONError(errors.New(msg)); resp.Code != "NETWORK_ERROR" { + t.Fatalf("code = %q for %q", resp.Code, msg) + } + } +} + +func TestGlobalJSONErrorOperationTimeoutIsNotNetwork(t *testing.T) { + // Operation and deadline timeouts are not transport failures; they must + // fall through so command-specific handlers or the generic fallback apply. + operationTimeouts := []string{ + "workflow cutover timed out waiting for traffic switch", + "context deadline exceeded", + "query timeout exceeded for SELECT", + } + for _, msg := range operationTimeouts { + if resp := GlobalJSONError(errors.New(msg)); resp.Code == "NETWORK_ERROR" { + t.Fatalf("misclassified %q as NETWORK_ERROR", msg) + } } } From ab8a035fd10d3da24fe6a6cdff9bb827e1350966 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Tue, 7 Jul 2026 16:54:40 -0500 Subject: [PATCH 4/9] Restore non-human format rejection for confirmation-gated workflow commands The JSON confirmation envelope replaced the old Format() != Human guard, which let --format csv fall through to interactive confirmation on a TTY for cancel, cutover, and switch-traffic. Reinstate the format error after the JSON branch so non-JSON machine formats fail fast without --force. Co-authored-by: Cursor --- internal/cmd/workflow/cancel.go | 4 +++ internal/cmd/workflow/cancel_test.go | 30 +++++++++++++++++++ internal/cmd/workflow/cutover.go | 4 +++ internal/cmd/workflow/cutover_test.go | 30 +++++++++++++++++++ internal/cmd/workflow/switch_traffic.go | 4 +++ internal/cmd/workflow/switch_traffic_test.go | 31 ++++++++++++++++++++ 6 files changed, 103 insertions(+) diff --git a/internal/cmd/workflow/cancel.go b/internal/cmd/workflow/cancel.go index d94fc4777..0c8b40285 100644 --- a/internal/cmd/workflow/cancel.go +++ b/internal/cmd/workflow/cancel.go @@ -46,6 +46,10 @@ marks it as cancelled, allowing you to start a new workflow if needed.`, cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cancel", "--force")) } + if ch.Printer.Format() != printer.Human { + return fmt.Errorf(`cannot cancel workflow with the output format "%s" (run with --force to override)`, ch.Printer.Format()) + } + if !printer.IsTTY { return fmt.Errorf("cannot confirm cancellation (run with --force to override)") } diff --git a/internal/cmd/workflow/cancel_test.go b/internal/cmd/workflow/cancel_test.go index 0a28320b5..9e1411859 100644 --- a/internal/cmd/workflow/cancel_test.go +++ b/internal/cmd/workflow/cancel_test.go @@ -89,6 +89,36 @@ func TestWorkflow_CancelCmd(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, expectedWorkflow) } +func TestWorkflow_CancelCmd_CSVWithoutForce(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.CSV + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.WorkflowsService{} + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: "planetscale", + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Workflows: svc, + }, nil + }, + } + + cmd := CancelCmd(ch) + cmd.SetArgs([]string{"planetscale", "123"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, `cannot cancel workflow with the output format "csv".*`) + c.Assert(svc.CancelFnInvoked, qt.IsFalse) +} + func TestWorkflow_CancelCmd_Error(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/workflow/cutover.go b/internal/cmd/workflow/cutover.go index 16ea52f07..b4df854fb 100644 --- a/internal/cmd/workflow/cutover.go +++ b/internal/cmd/workflow/cutover.go @@ -45,6 +45,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cutover", "--force")) } + if ch.Printer.Format() != printer.Human { + return fmt.Errorf(`cannot cutover with the output format "%s" (run with --force to override)`, ch.Printer.Format()) + } + if !printer.IsTTY { return fmt.Errorf("cannot confirm cutover (run with --force to override)") } diff --git a/internal/cmd/workflow/cutover_test.go b/internal/cmd/workflow/cutover_test.go index 6bb10b2ac..4a4d1a7b0 100644 --- a/internal/cmd/workflow/cutover_test.go +++ b/internal/cmd/workflow/cutover_test.go @@ -91,6 +91,36 @@ func TestWorkflow_CutoverCmd(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, expectedWorkflow) } +func TestWorkflow_CutoverCmd_CSVWithoutForce(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.CSV + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.WorkflowsService{} + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: "planetscale", + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Workflows: svc, + }, nil + }, + } + + cmd := CutoverCmd(ch) + cmd.SetArgs([]string{"planetscale", "123"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, `cannot cutover with the output format "csv".*`) + c.Assert(svc.CutoverFnInvoked, qt.IsFalse) +} + func TestWorkflow_CutoverCmd_Error(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/workflow/switch_traffic.go b/internal/cmd/workflow/switch_traffic.go index 14808fe67..ff88d22c7 100644 --- a/internal/cmd/workflow/switch_traffic.go +++ b/internal/cmd/workflow/switch_traffic.go @@ -54,6 +54,10 @@ By default, this command will route all queries for primary, replica, and read-o cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "switch-traffic", retryFlags...)) } + if ch.Printer.Format() != printer.Human { + return fmt.Errorf(`cannot switch query traffic with the output format "%s" (run with --force to override)`, ch.Printer.Format()) + } + if !printer.IsTTY { return fmt.Errorf("cannot confirm switching query traffic (run with --force to override)") } diff --git a/internal/cmd/workflow/switch_traffic_test.go b/internal/cmd/workflow/switch_traffic_test.go index 1b39a7aae..de1377b90 100644 --- a/internal/cmd/workflow/switch_traffic_test.go +++ b/internal/cmd/workflow/switch_traffic_test.go @@ -163,6 +163,37 @@ func TestWorkflow_SwitchTrafficCmd_Replicas(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, expectedWorkflow) } +func TestWorkflow_SwitchTrafficCmd_CSVWithoutForce(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.CSV + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + svc := &mock.WorkflowsService{} + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: "planetscale", + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Workflows: svc, + }, nil + }, + } + + cmd := SwitchTrafficCmd(ch) + cmd.SetArgs([]string{"planetscale", "123"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, `cannot switch query traffic with the output format "csv".*`) + c.Assert(svc.SwitchPrimariesFnInvoked, qt.IsFalse) + c.Assert(svc.SwitchReplicasFnInvoked, qt.IsFalse) +} + func TestWorkflow_SwitchTrafficCmd_Error(t *testing.T) { c := qt.New(t) From 0651e90e9bc07b375864a0e6a8988ce5c2c0914d Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 8 Jul 2026 14:52:57 -0500 Subject: [PATCH 5/9] Revert workflow-specific JSON error handling to focus on the global envelope. Co-authored-by: Cursor --- internal/cmd/workflow/cancel.go | 17 +-- internal/cmd/workflow/cancel_test.go | 30 ---- internal/cmd/workflow/complete.go | 11 +- internal/cmd/workflow/create.go | 28 +--- internal/cmd/workflow/cutover.go | 23 ++- internal/cmd/workflow/cutover_test.go | 30 ---- internal/cmd/workflow/json.go | 114 --------------- internal/cmd/workflow/json_test.go | 139 ------------------- internal/cmd/workflow/list.go | 9 +- internal/cmd/workflow/retry.go | 11 +- internal/cmd/workflow/reverse_cutover.go | 11 +- internal/cmd/workflow/reverse_traffic.go | 11 +- internal/cmd/workflow/show.go | 11 +- internal/cmd/workflow/switch_traffic.go | 21 +-- internal/cmd/workflow/switch_traffic_test.go | 31 ----- internal/cmd/workflow/verify_data.go | 11 +- internal/cmdutil/agent_steps.go | 81 ++++------- internal/cmdutil/agent_steps_test.go | 6 +- 18 files changed, 84 insertions(+), 511 deletions(-) delete mode 100644 internal/cmd/workflow/json.go delete mode 100644 internal/cmd/workflow/json_test.go diff --git a/internal/cmd/workflow/cancel.go b/internal/cmd/workflow/cancel.go index 0c8b40285..b91ebf52c 100644 --- a/internal/cmd/workflow/cancel.go +++ b/internal/cmd/workflow/cancel.go @@ -26,26 +26,19 @@ marks it as cancelled, allowing you to start a new workflow if needed.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } if !force { - if ch.Printer.Format() == printer.JSON { - return wfCtx.reportConfirmationRequired(ch, "CANCEL_CONFIRMATION_REQUIRED", - "Cancel stops the workflow and marks it as cancelled", - cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cancel", "--force")) - } - if ch.Printer.Format() != printer.Human { return fmt.Errorf(`cannot cancel workflow with the output format "%s" (run with --force to override)`, ch.Printer.Format()) } @@ -85,10 +78,10 @@ marks it as cancelled, allowing you to start a new workflow if needed.`, if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/cancel_test.go b/internal/cmd/workflow/cancel_test.go index 9e1411859..0a28320b5 100644 --- a/internal/cmd/workflow/cancel_test.go +++ b/internal/cmd/workflow/cancel_test.go @@ -89,36 +89,6 @@ func TestWorkflow_CancelCmd(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, expectedWorkflow) } -func TestWorkflow_CancelCmd_CSVWithoutForce(t *testing.T) { - c := qt.New(t) - - var buf bytes.Buffer - format := printer.CSV - p := printer.NewPrinter(&format) - p.SetResourceOutput(&buf) - - svc := &mock.WorkflowsService{} - - ch := &cmdutil.Helper{ - Printer: p, - Config: &config.Config{ - Organization: "planetscale", - }, - Client: func() (*ps.Client, error) { - return &ps.Client{ - Workflows: svc, - }, nil - }, - } - - cmd := CancelCmd(ch) - cmd.SetArgs([]string{"planetscale", "123"}) - err := cmd.Execute() - - c.Assert(err, qt.ErrorMatches, `cannot cancel workflow with the output format "csv".*`) - c.Assert(svc.CancelFnInvoked, qt.IsFalse) -} - func TestWorkflow_CancelCmd_Error(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/workflow/complete.go b/internal/cmd/workflow/complete.go index aa1a1c890..391f87b46 100644 --- a/internal/cmd/workflow/complete.go +++ b/internal/cmd/workflow/complete.go @@ -19,17 +19,16 @@ func CompleteCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Marking workflow %s in database %s as complete…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db))) @@ -43,10 +42,10 @@ func CompleteCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/create.go b/internal/cmd/workflow/create.go index d35fdad02..4e3731305 100644 --- a/internal/cmd/workflow/create.go +++ b/internal/cmd/workflow/create.go @@ -34,21 +34,18 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, branch := args[0], args[1] - org := ch.Config.Organization client, err := ch.Client() if err != nil { - return errorContext{Org: org, Database: db, Branch: branch}.handle(ch, err) + return err } + org := ch.Config.Organization + if flags.interactive { return createInteractive(ctx, ch, org, db, branch, flags) } - if err := validateCreateFlags(flags); err != nil { - return reportMissingCreateFlags(ch, org, db, branch, err) - } - end := ch.Printer.PrintProgress("Creating workflow…") defer end() @@ -56,7 +53,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { end() if err != nil { - return errorContext{Org: org, Database: db, Branch: branch}.handle(ch, err) + return err } if ch.Printer.Format() == printer.Human { @@ -114,23 +111,6 @@ func createWorkflow(ctx context.Context, client *ps.Client, org, db, branch stri return workflow, nil } -func validateCreateFlags(flags createFlags) error { - var missing []string - if flags.sourceKeyspace == "" { - missing = append(missing, "--source-keyspace") - } - if flags.targetKeyspace == "" { - missing = append(missing, "--target-keyspace") - } - if len(flags.tables) == 0 { - missing = append(missing, "--tables") - } - if len(missing) == 0 { - return nil - } - return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", ")) -} - func createInteractive(ctx context.Context, ch *cmdutil.Helper, org, db, branch string, flags createFlags) error { client, err := ch.Client() if err != nil { diff --git a/internal/cmd/workflow/cutover.go b/internal/cmd/workflow/cutover.go index b4df854fb..5609030f7 100644 --- a/internal/cmd/workflow/cutover.go +++ b/internal/cmd/workflow/cutover.go @@ -25,26 +25,19 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } if !force { - if ch.Printer.Format() == printer.JSON { - return wfCtx.reportConfirmationRequired(ch, "CUTOVER_CONFIRMATION_REQUIRED", - "Cutover deletes moved tables from the source keyspace and ends replication", - cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "cutover", "--force")) - } - if ch.Printer.Format() != printer.Human { return fmt.Errorf(`cannot cutover with the output format "%s" (run with --force to override)`, ch.Printer.Format()) } @@ -61,10 +54,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } @@ -100,10 +93,10 @@ func CutoverCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/cutover_test.go b/internal/cmd/workflow/cutover_test.go index 4a4d1a7b0..6bb10b2ac 100644 --- a/internal/cmd/workflow/cutover_test.go +++ b/internal/cmd/workflow/cutover_test.go @@ -91,36 +91,6 @@ func TestWorkflow_CutoverCmd(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, expectedWorkflow) } -func TestWorkflow_CutoverCmd_CSVWithoutForce(t *testing.T) { - c := qt.New(t) - - var buf bytes.Buffer - format := printer.CSV - p := printer.NewPrinter(&format) - p.SetResourceOutput(&buf) - - svc := &mock.WorkflowsService{} - - ch := &cmdutil.Helper{ - Printer: p, - Config: &config.Config{ - Organization: "planetscale", - }, - Client: func() (*ps.Client, error) { - return &ps.Client{ - Workflows: svc, - }, nil - }, - } - - cmd := CutoverCmd(ch) - cmd.SetArgs([]string{"planetscale", "123"}) - err := cmd.Execute() - - c.Assert(err, qt.ErrorMatches, `cannot cutover with the output format "csv".*`) - c.Assert(svc.CutoverFnInvoked, qt.IsFalse) -} - func TestWorkflow_CutoverCmd_Error(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/workflow/json.go b/internal/cmd/workflow/json.go deleted file mode 100644 index 0856ddd88..000000000 --- a/internal/cmd/workflow/json.go +++ /dev/null @@ -1,114 +0,0 @@ -package workflow - -import ( - "strings" - - "github.com/planetscale/cli/internal/cmdutil" - "github.com/planetscale/cli/internal/printer" - ps "github.com/planetscale/planetscale-go/planetscale" -) - -// errorContext carries workflow command context so JSON error responses can -// include remediation commands with real values instead of placeholders. -type errorContext struct { - Org string - Database string - Branch string - Number string -} - -// handle converts err into a structured JSON error when the printer is in -// JSON mode; in human mode it returns err unchanged. -func (c errorContext) handle(ch *cmdutil.Helper, err error) error { - if err == nil || ch.Printer.Format() != printer.JSON { - return err - } - if cmdutil.ErrCode(err) == ps.ErrNotFound || strings.Contains(err.Error(), "does not exist") { - return c.report(ch, "error", "NOT_FOUND", err.Error(), c.notFoundNextSteps()) - } - // Failures that are not workflow-specific (auth, network, usage) get the - // same code and remediation the global classifier would give on any other - // command, so an expired token reports NO_AUTH with login steps rather - // than WORKFLOW_ERROR. - if resp := cmdutil.GlobalJSONError(err); resp.Code != "COMMAND_FAILED" { - return c.report(ch, resp.Status, resp.Code, resp.Error, resp.NextSteps) - } - return c.report(ch, "error", "WORKFLOW_ERROR", err.Error(), c.defaultNextSteps()) -} - -// reportConfirmationRequired reports a confirmation-gated action in JSON mode. -// Callers gate on JSON format before calling. -func (c errorContext) reportConfirmationRequired(ch *cmdutil.Helper, code, message, retryCmd string) error { - return c.report(ch, "action_required", code, message, []string{ - "Ask the user to approve this workflow action", - retryCmd, - }) -} - -func reportMissingCreateFlags(ch *cmdutil.Helper, org, database, branch string, err error) error { - if ch.Printer.Format() != printer.JSON { - return err - } - c := errorContext{Org: org, Database: database, Branch: branch} - return c.report(ch, "action_required", "MISSING_FLAGS", err.Error(), []string{ - cmdutil.AgentKeyspaceListCmd(org, database, branch), - cmdutil.AgentWorkflowCreateCmd(org, database, branch), - }) -} - -func (c errorContext) report(ch *cmdutil.Helper, status, code, message string, nextSteps []string) error { - payload := map[string]any{ - "status": status, - "error": message, - "issues": []map[string]string{ - { - "code": code, - "message": message, - }, - }, - "next_steps": nextSteps, - } - if c.Database != "" { - payload["database"] = c.Database - } - if c.Branch != "" { - payload["branch"] = c.Branch - } - if c.Number != "" { - payload["workflow_number"] = c.Number - } - - if err := ch.Printer.PrintJSON(payload); err != nil { - return err - } - exitCode := cmdutil.FatalErrExitCode - if status == "action_required" { - exitCode = cmdutil.ActionRequestedExitCode - } - return cmdutil.JSONReportedError(exitCode) -} - -func (c errorContext) notFoundNextSteps() []string { - steps := []string{ - cmdutil.AgentDatabaseListCmd(c.Org), - cmdutil.AgentWorkflowListCmd(c.Org, c.Database), - } - if c.Branch != "" { - steps = append(steps, cmdutil.AgentKeyspaceListCmd(c.Org, c.Database, c.Branch)) - } - if c.Number != "" { - steps = append(steps, cmdutil.AgentWorkflowShowCmd(c.Org, c.Database, c.Number)) - } - return steps -} - -func (c errorContext) defaultNextSteps() []string { - steps := []string{cmdutil.AgentAuthCheckCmd()} - if c.Database != "" { - steps = append(steps, cmdutil.AgentWorkflowListCmd(c.Org, c.Database)) - if c.Number != "" { - steps = append(steps, cmdutil.AgentWorkflowShowCmd(c.Org, c.Database, c.Number)) - } - } - return steps -} diff --git a/internal/cmd/workflow/json_test.go b/internal/cmd/workflow/json_test.go deleted file mode 100644 index d3a79605a..000000000 --- a/internal/cmd/workflow/json_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package workflow - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - - "github.com/planetscale/cli/internal/cmdutil" - "github.com/planetscale/cli/internal/printer" -) - -func TestHandleWorkflowErrorMissingFlagsJSON(t *testing.T) { - format := printer.JSON - var out bytes.Buffer - ch := &cmdutil.Helper{ - Printer: printer.NewPrinter(&format), - } - ch.Printer.SetResourceOutput(&out) - - err := reportMissingCreateFlags(ch, "bb", "mydb", "main", errors.New("missing required flags: --source-keyspace, --tables")) - if err == nil { - t.Fatal("expected handled JSON error") - } - if cmdErr, ok := errors.AsType[*cmdutil.Error](err); !ok || !cmdErr.Handled { - t.Fatalf("expected handled JSON error, got %v", err) - } - - var resp map[string]any - if err := json.Unmarshal(out.Bytes(), &resp); err != nil { - t.Fatalf("json: %v", err) - } - if resp["status"] != "action_required" { - t.Fatalf("status = %v", resp["status"]) - } - nextSteps, ok := resp["next_steps"].([]any) - if !ok || len(nextSteps) < 2 { - t.Fatalf("next_steps = %v", resp["next_steps"]) - } -} - -func TestHandleWorkflowErrorNotFoundJSON(t *testing.T) { - format := printer.JSON - var out bytes.Buffer - ch := &cmdutil.Helper{ - Printer: printer.NewPrinter(&format), - } - ch.Printer.SetResourceOutput(&out) - - ctx := errorContext{Org: "bb", Database: "mydb", Branch: "main"} - err := ctx.handle(ch, errors.New("database mydb does not exist in organization bb")) - if err == nil { - t.Fatal("expected handled JSON error") - } - - var resp map[string]any - if err := json.Unmarshal(out.Bytes(), &resp); err != nil { - t.Fatalf("json: %v", err) - } - if resp["status"] != "error" { - t.Fatalf("status = %v", resp["status"]) - } -} - -func TestReportConfirmationRequiredJSON(t *testing.T) { - format := printer.JSON - var out bytes.Buffer - ch := &cmdutil.Helper{ - Printer: printer.NewPrinter(&format), - } - ch.Printer.SetResourceOutput(&out) - - ctx := errorContext{Org: "bb", Database: "mydb", Number: "1"} - err := ctx.reportConfirmationRequired(ch, "CUTOVER_CONFIRMATION_REQUIRED", - "Cutover deletes moved tables from the source keyspace and ends replication", - cmdutil.AgentWorkflowActionCmd("bb", "mydb", "1", "cutover", "--force")) - if err == nil { - t.Fatal("expected handled JSON error") - } - - var resp map[string]any - if err := json.Unmarshal(out.Bytes(), &resp); err != nil { - t.Fatalf("json: %v", err) - } - if resp["status"] != "action_required" { - t.Fatalf("status = %v", resp["status"]) - } -} - -func TestHandleWorkflowErrorAuthFailureJSON(t *testing.T) { - format := printer.JSON - var out bytes.Buffer - ch := &cmdutil.Helper{ - Printer: printer.NewPrinter(&format), - } - ch.Printer.SetResourceOutput(&out) - - ctx := errorContext{Org: "bb", Database: "mydb", Number: "1"} - err := ctx.handle(ch, errors.New("the access token has expired. Please run 'pscale auth login'")) - if err == nil { - t.Fatal("expected handled JSON error") - } - if cmdErr, ok := errors.AsType[*cmdutil.Error](err); !ok || !cmdErr.Handled || cmdErr.ExitCode != cmdutil.ActionRequestedExitCode { - t.Fatalf("expected handled action_required error, got %v", err) - } - - var resp map[string]any - if err := json.Unmarshal(out.Bytes(), &resp); err != nil { - t.Fatalf("json: %v", err) - } - if resp["status"] != "action_required" { - t.Fatalf("status = %v", resp["status"]) - } - issues, ok := resp["issues"].([]any) - if !ok || len(issues) == 0 { - t.Fatalf("issues = %v", resp["issues"]) - } - issue, ok := issues[0].(map[string]any) - if !ok || issue["code"] != "NO_AUTH" { - t.Fatalf("issue = %v", issues[0]) - } - nextSteps, ok := resp["next_steps"].([]any) - if !ok || len(nextSteps) == 0 || nextSteps[0] != cmdutil.AgentAuthLoginCmd() { - t.Fatalf("next_steps = %v", resp["next_steps"]) - } -} - -func TestHandleWorkflowErrorHumanModePassthrough(t *testing.T) { - format := printer.Human - ch := &cmdutil.Helper{ - Printer: printer.NewPrinter(&format), - } - - orig := errors.New("database mydb does not exist in organization bb") - err := errorContext{Org: "bb", Database: "mydb"}.handle(ch, orig) - if !errors.Is(err, orig) { - t.Fatalf("expected original error, got %v", err) - } -} diff --git a/internal/cmd/workflow/list.go b/internal/cmd/workflow/list.go index 43b854176..bf6ec52e9 100644 --- a/internal/cmd/workflow/list.go +++ b/internal/cmd/workflow/list.go @@ -22,11 +22,10 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db := args[0] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching workflows for database %s…", printer.BoldBlue(db))) @@ -39,10 +38,10 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/retry.go b/internal/cmd/workflow/retry.go index b6ab38aea..8e41b34bd 100644 --- a/internal/cmd/workflow/retry.go +++ b/internal/cmd/workflow/retry.go @@ -19,17 +19,16 @@ func RetryCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Retrying workflow %s in database %s…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db))) @@ -43,10 +42,10 @@ func RetryCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/reverse_cutover.go b/internal/cmd/workflow/reverse_cutover.go index 15091c339..61b22387c 100644 --- a/internal/cmd/workflow/reverse_cutover.go +++ b/internal/cmd/workflow/reverse_cutover.go @@ -20,17 +20,16 @@ This is useful if your application has errors and you need to rollback after a c RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Reversing cutover for workflow %s in database %s…", printer.BoldBlue(printer.Number(number)), printer.BoldBlue(db))) @@ -44,10 +43,10 @@ This is useful if your application has errors and you need to rollback after a c if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } diff --git a/internal/cmd/workflow/reverse_traffic.go b/internal/cmd/workflow/reverse_traffic.go index ff042fae5..d7eb0aba7 100644 --- a/internal/cmd/workflow/reverse_traffic.go +++ b/internal/cmd/workflow/reverse_traffic.go @@ -20,17 +20,16 @@ This is useful when you need to rollback a traffic switch operation.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Reversing traffic for workflow %s in database %s…", printer.BoldBlue(number), printer.BoldBlue(db))) @@ -44,10 +43,10 @@ This is useful when you need to rollback a traffic switch operation.`, if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/show.go b/internal/cmd/workflow/show.go index e8889b2e3..196ef9347 100644 --- a/internal/cmd/workflow/show.go +++ b/internal/cmd/workflow/show.go @@ -20,17 +20,16 @@ func ShowCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching workflow %s for database %s…", printer.BoldBlue(number), printer.BoldBlue(db))) @@ -44,10 +43,10 @@ func ShowCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/switch_traffic.go b/internal/cmd/workflow/switch_traffic.go index ff88d22c7..69301afb7 100644 --- a/internal/cmd/workflow/switch_traffic.go +++ b/internal/cmd/workflow/switch_traffic.go @@ -27,33 +27,22 @@ By default, this command will route all queries for primary, replica, and read-o RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } var workflow *ps.Workflow var end func() if !force { - if ch.Printer.Format() == printer.JSON { - retryFlags := []string{"--force"} - if replicasOnly { - retryFlags = []string{"--replicas-only", "--force"} - } - return wfCtx.reportConfirmationRequired(ch, "SWITCH_TRAFFIC_CONFIRMATION_REQUIRED", - "Switch traffic routes queries to the target keyspace for this workflow", - cmdutil.AgentWorkflowActionCmd(wfCtx.Org, db, num, "switch-traffic", retryFlags...)) - } - if ch.Printer.Format() != printer.Human { return fmt.Errorf(`cannot switch query traffic with the output format "%s" (run with --force to override)`, ch.Printer.Format()) } @@ -108,10 +97,10 @@ By default, this command will route all queries for primary, replica, and read-o if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmd/workflow/switch_traffic_test.go b/internal/cmd/workflow/switch_traffic_test.go index de1377b90..1b39a7aae 100644 --- a/internal/cmd/workflow/switch_traffic_test.go +++ b/internal/cmd/workflow/switch_traffic_test.go @@ -163,37 +163,6 @@ func TestWorkflow_SwitchTrafficCmd_Replicas(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, expectedWorkflow) } -func TestWorkflow_SwitchTrafficCmd_CSVWithoutForce(t *testing.T) { - c := qt.New(t) - - var buf bytes.Buffer - format := printer.CSV - p := printer.NewPrinter(&format) - p.SetResourceOutput(&buf) - - svc := &mock.WorkflowsService{} - - ch := &cmdutil.Helper{ - Printer: p, - Config: &config.Config{ - Organization: "planetscale", - }, - Client: func() (*ps.Client, error) { - return &ps.Client{ - Workflows: svc, - }, nil - }, - } - - cmd := SwitchTrafficCmd(ch) - cmd.SetArgs([]string{"planetscale", "123"}) - err := cmd.Execute() - - c.Assert(err, qt.ErrorMatches, `cannot switch query traffic with the output format "csv".*`) - c.Assert(svc.SwitchPrimariesFnInvoked, qt.IsFalse) - c.Assert(svc.SwitchReplicasFnInvoked, qt.IsFalse) -} - func TestWorkflow_SwitchTrafficCmd_Error(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/workflow/verify_data.go b/internal/cmd/workflow/verify_data.go index a47e21459..2ed2e152d 100644 --- a/internal/cmd/workflow/verify_data.go +++ b/internal/cmd/workflow/verify_data.go @@ -18,17 +18,16 @@ func VerifyDataCmd(ch *cmdutil.Helper) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() db, num := args[0], args[1] - wfCtx := errorContext{Org: ch.Config.Organization, Database: db, Number: num} client, err := ch.Client() if err != nil { - return wfCtx.handle(ch, err) + return err } var number uint64 number, err = strconv.ParseUint(num, 10, 64) if err != nil { - return wfCtx.handle(ch, err) + return err } end := ch.Printer.PrintProgress(fmt.Sprintf("Verifying data for workflow %s in database %s…", printer.BoldBlue(number), printer.BoldBlue(db))) @@ -42,10 +41,10 @@ func VerifyDataCmd(ch *cmdutil.Helper) *cobra.Command { if err != nil { switch cmdutil.ErrCode(err) { case ps.ErrNotFound: - return wfCtx.handle(ch, fmt.Errorf("database %s or workflow %s does not exist in organization %s", - printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))) + return fmt.Errorf("database %s or workflow %s does not exist in organization %s", + printer.BoldBlue(db), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization)) default: - return wfCtx.handle(ch, cmdutil.HandleError(err)) + return cmdutil.HandleError(err) } } end() diff --git a/internal/cmdutil/agent_steps.go b/internal/cmdutil/agent_steps.go index cdc67b6f5..0058ef9a8 100644 --- a/internal/cmdutil/agent_steps.go +++ b/internal/cmdutil/agent_steps.go @@ -1,12 +1,8 @@ package cmdutil -import ( - "fmt" - "strings" -) +import "fmt" -// Agent command strings for next_steps — flags go after the subcommand (not on -// pscale root). Empty values render as for the agent to fill in. +// Agent command strings for next_steps — flags go after the subcommand (not on pscale root). func AgentGuideCmd() string { return "pscale agent-guide --format json" @@ -25,62 +21,37 @@ func AgentOrgListCmd() string { } func AgentDatabaseListCmd(org string) string { - return fmt.Sprintf("pscale database list --org %s --format json", orPlaceholder(org, "")) + if org == "" { + return "pscale database list --org --format json" + } + return fmt.Sprintf("pscale database list --org %s --format json", org) } func AgentBranchListCmd(org, database string) string { - return fmt.Sprintf("pscale branch list %s --org %s --format json", - orPlaceholder(database, ""), orPlaceholder(org, "")) + if database == "" { + database = "" + } + if org == "" { + return fmt.Sprintf("pscale branch list %s --org --format json", database) + } + return fmt.Sprintf("pscale branch list %s --org %s --format json", database, org) } func AgentSQLCmd(org, database, branch string, force bool) string { - forceFlag, query := "", "SELECT 1" - if force { - forceFlag, query = " --force", "" + if database == "" { + database = "" } - return fmt.Sprintf("pscale sql %s %s --org %s --format json%s --query %q", - orPlaceholder(database, ""), orPlaceholder(branch, ""), - orPlaceholder(org, ""), forceFlag, query) -} - -func AgentWorkflowListCmd(org, database string) string { - return fmt.Sprintf("pscale workflow list %s --org %s --format json", - orPlaceholder(database, ""), orPlaceholder(org, "")) -} - -func AgentWorkflowShowCmd(org, database, number string) string { - return fmt.Sprintf("pscale workflow show %s %s --org %s --format json", - orPlaceholder(database, ""), orPlaceholder(number, ""), - orPlaceholder(org, "")) -} - -func AgentWorkflowCreateCmd(org, database, branch string) string { - return fmt.Sprintf("pscale workflow create %s %s --org %s --format json --source-keyspace --target-keyspace --tables
", - orPlaceholder(database, ""), orPlaceholder(branch, ""), - orPlaceholder(org, "")) -} - -func AgentKeyspaceListCmd(org, database, branch string) string { - return fmt.Sprintf("pscale keyspace list %s %s --org %s --format json", - orPlaceholder(database, ""), orPlaceholder(branch, ""), - orPlaceholder(org, "")) -} - -// AgentWorkflowActionCmd renders a workflow subcommand like cutover or cancel, -// with extraFlags appended after --format json. -func AgentWorkflowActionCmd(org, database, number, action string, extraFlags ...string) string { - flags := "" - if len(extraFlags) > 0 { - flags = " " + strings.Join(extraFlags, " ") + if branch == "" { + branch = "" } - return fmt.Sprintf("pscale workflow %s %s %s --org %s --format json%s", - action, orPlaceholder(database, ""), orPlaceholder(number, ""), - orPlaceholder(org, ""), flags) -} - -func orPlaceholder(s, placeholder string) string { - if s == "" { - return placeholder + query := "SELECT 1" + forceFlag := "" + if force { + forceFlag = " --force" + query = "" + } + if org == "" { + return fmt.Sprintf("pscale sql %s %s --org --format json%s --query \"%s\"", database, branch, forceFlag, query) } - return s + return fmt.Sprintf("pscale sql %s %s --org %s --format json%s --query \"%s\"", database, branch, org, forceFlag, query) } diff --git a/internal/cmdutil/agent_steps_test.go b/internal/cmdutil/agent_steps_test.go index 67e3370f6..f68812374 100644 --- a/internal/cmdutil/agent_steps_test.go +++ b/internal/cmdutil/agent_steps_test.go @@ -16,9 +16,6 @@ func TestAgentStepsFlagOrder(t *testing.T) { {name: "database list", got: AgentDatabaseListCmd("bb")}, {name: "branch list", got: AgentBranchListCmd("bb", "mydb")}, {name: "sql", got: AgentSQLCmd("bb", "mydb", "main", false)}, - {name: "workflow list", got: AgentWorkflowListCmd("bb", "mydb")}, - {name: "workflow create", got: AgentWorkflowCreateCmd("bb", "mydb", "main")}, - {name: "workflow cutover", got: AgentWorkflowActionCmd("bb", "mydb", "1", "cutover", "--force")}, } for _, tt := range tests { @@ -44,7 +41,8 @@ func TestCheckAuthenticationJSONHandledError(t *testing.T) { if err == nil { t.Fatal("expected auth error") } - if cmdErr, ok := errors.AsType[*Error](err); !ok || !cmdErr.Handled { + var cmdErr *Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { t.Fatalf("expected handled JSON error, got %v", err) } } From b7ce77a05d7f7c01a308838eb7689a2698cd0ab5 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 8 Jul 2026 14:52:57 -0500 Subject: [PATCH 6/9] Unify JSON error envelope on the issues schema, strip ANSI codes, document codes. Co-authored-by: Cursor --- AGENTS.md | 38 ++++++++++++- internal/cmd/sql/json.go | 6 +++ internal/cmdutil/json_error.go | 37 ++++++++++--- internal/cmdutil/json_error_test.go | 84 +++++++++++++++++++++-------- 4 files changed, 136 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b21e8e8de..19974e8d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,42 @@ pscale --org database list --format json | `--org ` | Organization (on resource subcommands only) | | `--api-url` | Non-production API base URL — pass on every command when not using production | +## JSON errors + +With `--format json`, any command that fails prints exactly one JSON envelope on **stdout**: + +```json +{ + "status": "error", + "error": "human-readable message", + "issues": [{ "code": "NOT_FOUND", "message": "human-readable message" }], + "next_steps": ["pscale org list --format json", "pscale database list --org --format json"] +} +``` + +- `status` is `"error"` or `"action_required"`. `action_required` means an agent can recover by following `next_steps` (log in, ask the user for approval, fix the invocation). Exit code is `1` for `action_required` and `2` for `error`. +- `issues[].code` is stable and machine-readable; branch on it, not on message text. +- `next_steps` are concrete commands or instructions, ordered by likelihood. + +Some commands add fields to this envelope (for example `query_kind` on destructive SQL or `migration_id` on imports) but `status`, `issues`, and `next_steps` are always present on failure. + +| Code | Meaning | +|------|---------| +| `NO_AUTH` | Not authenticated or token expired; run `pscale auth login --format json` | +| `AUTH_INVALID` | Stored credentials rejected by the API; log in again | +| `SERVICE_TOKEN_INVALID` | Service token id/secret rejected; verify the values | +| `NO_ORG` | Authenticated but no organization configured | +| `INVALID_FLAG_PLACEMENT` | `--org` was passed on `pscale` root; move it to the subcommand | +| `INVALID_USAGE` | Missing arguments or required flags; the message names them | +| `UNKNOWN_COMMAND` | Command does not exist; check `pscale --help` | +| `UNKNOWN_FLAG` | Flag does not exist on this command; check `--help` | +| `TTY_REQUIRED` | Command needs an interactive terminal; use the JSON alternative in `next_steps` | +| `CONFIRMATION_REQUIRED` | Destructive or gated action; ask the user, then re-run with `--force` | +| `DESTRUCTIVE_SQL` | Query would delete data or schema; ask the user, then re-run with `--force` | +| `NOT_FOUND` | Org, database, branch, or resource does not exist; run the discovery commands | +| `NETWORK_ERROR` | Transport-level failure; check connectivity and `--api-url`, then retry | +| `COMMAND_FAILED` | Unclassified failure; read `error` and rule out auth first | + ## Authentication `pscale auth login` stores credentials in the OS keychain; agents on the same machine reuse them. @@ -153,7 +189,7 @@ Success: `status`, `database`, `branch`, `kind` (`mysql` or `postgresql`), `role MySQL may return synthetic column names (e.g. `:vtg1 /* INT64 */`). PostgreSQL may use names like `?column?`. -Error: one JSON object on stdout with `status: "error"`, `error`, and `next_steps`. +Error: one JSON object on stdout with `status: "error"`, `error`, `issues`, and `next_steps` (see JSON errors above). Destructive SQL without `--force`: `status: "action_required"`, `query_kind: "destructive"`, `issues`, and `next_steps` (includes `--force` retry command). diff --git a/internal/cmd/sql/json.go b/internal/cmd/sql/json.go index a0ace00e0..12faad0b0 100644 --- a/internal/cmd/sql/json.go +++ b/internal/cmd/sql/json.go @@ -42,6 +42,12 @@ func handleExecuteError(ch *cmdutil.Helper, err error, database, branch string) return reportJSON(ch, map[string]any{ "status": "error", "error": err.Error(), + "issues": []map[string]string{ + { + "code": "COMMAND_FAILED", + "message": err.Error(), + }, + }, "next_steps": []string{ cmdutil.AgentAuthCheckCmd(), cmdutil.AgentSQLCmd(ch.Config.Organization, database, branch, false), diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go index 9d4532412..1f40ca084 100644 --- a/internal/cmdutil/json_error.go +++ b/internal/cmdutil/json_error.go @@ -5,17 +5,40 @@ import ( "errors" "fmt" "io" + "regexp" "strings" ) -// JSONErrorResponse is the fallback JSON envelope for unhandled command errors. +// JSONErrorIssue mirrors the issue shape used by auth check, sql, and import +// d1 responses: a stable machine-readable code plus the human message. +type JSONErrorIssue struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// JSONErrorResponse is the fallback JSON envelope for unhandled command +// errors. It follows the same schema as command-specific error responses: +// status, error, issues (code + message), next_steps. type JSONErrorResponse struct { - Status string `json:"status"` - Code string `json:"code,omitempty"` - Error string `json:"error"` - NextSteps []string `json:"next_steps"` + Status string `json:"status"` + Error string `json:"error"` + Issues []JSONErrorIssue `json:"issues"` + NextSteps []string `json:"next_steps"` } +// Code returns the classification code of the first issue. +func (r JSONErrorResponse) Code() string { + if len(r.Issues) == 0 { + return "" + } + return r.Issues[0].Code +} + +// ansiEscape matches CSI color/style sequences. Error messages built for +// human output may embed them (e.g. via printer.BoldBlue); they must never +// reach a JSON payload. +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + // GlobalJSONError classifies an error for the global JSON envelope. // // Classification is by message text because this is the top-level fallback: @@ -36,6 +59,8 @@ func GlobalJSONError(err error) JSONErrorResponse { } } + msg = ansiEscape.ReplaceAllString(msg, "") + var nextSteps []string lower := strings.ToLower(msg) @@ -140,8 +165,8 @@ func GlobalJSONError(err error) JSONErrorResponse { return JSONErrorResponse{ Status: status, - Code: code, Error: msg, + Issues: []JSONErrorIssue{{Code: code, Message: msg}}, NextSteps: nextSteps, } } diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go index 07529af12..16c515de7 100644 --- a/internal/cmdutil/json_error_test.go +++ b/internal/cmdutil/json_error_test.go @@ -12,8 +12,8 @@ func TestGlobalJSONErrorAuthRequired(t *testing.T) { if resp.Status != "action_required" { t.Fatalf("status = %q", resp.Status) } - if resp.Code != "NO_AUTH" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "NO_AUTH" { + t.Fatalf("code = %q", resp.Code()) } if len(resp.NextSteps) == 0 || resp.NextSteps[0] != AgentAuthLoginCmd() { t.Fatalf("next_steps = %#v", resp.NextSteps) @@ -25,8 +25,8 @@ func TestGlobalJSONErrorInvalidUsageSkipsAuth(t *testing.T) { if resp.Status != "action_required" { t.Fatalf("status = %q", resp.Status) } - if resp.Code != "INVALID_USAGE" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "INVALID_USAGE" { + t.Fatalf("code = %q", resp.Code()) } for _, step := range resp.NextSteps { if step == AgentAuthCheckCmd() || step == AgentAuthLoginCmd() { @@ -37,8 +37,8 @@ func TestGlobalJSONErrorInvalidUsageSkipsAuth(t *testing.T) { func TestGlobalJSONErrorOrgFlagPlacement(t *testing.T) { resp := GlobalJSONError(errors.New("unknown flag: --org")) - if resp.Code != "INVALID_FLAG_PLACEMENT" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "INVALID_FLAG_PLACEMENT" { + t.Fatalf("code = %q", resp.Code()) } if len(resp.NextSteps) != 2 || resp.NextSteps[1] != AgentDatabaseListCmd("") { t.Fatalf("next_steps = %#v", resp.NextSteps) @@ -47,15 +47,15 @@ func TestGlobalJSONErrorOrgFlagPlacement(t *testing.T) { func TestGlobalJSONErrorUnknownFlag(t *testing.T) { resp := GlobalJSONError(errors.New("unknown flag: --bogus")) - if resp.Code != "UNKNOWN_FLAG" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "UNKNOWN_FLAG" { + t.Fatalf("code = %q", resp.Code()) } } func TestGlobalJSONErrorUnknownCommand(t *testing.T) { resp := GlobalJSONError(errors.New(`unknown command "wat" for "pscale"`)) - if resp.Code != "UNKNOWN_COMMAND" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "UNKNOWN_COMMAND" { + t.Fatalf("code = %q", resp.Code()) } for _, step := range resp.NextSteps { if step == AgentAuthCheckCmd() { @@ -69,8 +69,8 @@ func TestGlobalJSONErrorConfirmationRequired(t *testing.T) { if resp.Status != "action_required" { t.Fatalf("status = %q", resp.Status) } - if resp.Code != "CONFIRMATION_REQUIRED" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q", resp.Code()) } if len(resp.NextSteps) != 2 { t.Fatalf("next_steps = %#v", resp.NextSteps) @@ -79,8 +79,8 @@ func TestGlobalJSONErrorConfirmationRequired(t *testing.T) { func TestGlobalJSONErrorNotFound(t *testing.T) { resp := GlobalJSONError(errors.New("database foo does not exist in organization bar")) - if resp.Code != "NOT_FOUND" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "NOT_FOUND" { + t.Fatalf("code = %q", resp.Code()) } if len(resp.NextSteps) == 0 || resp.NextSteps[0] != AgentOrgListCmd() { t.Fatalf("next_steps = %#v", resp.NextSteps) @@ -96,8 +96,8 @@ func TestGlobalJSONErrorNetwork(t *testing.T) { "net/http: TLS handshake timeout", } for _, msg := range networkErrors { - if resp := GlobalJSONError(errors.New(msg)); resp.Code != "NETWORK_ERROR" { - t.Fatalf("code = %q for %q", resp.Code, msg) + if resp := GlobalJSONError(errors.New(msg)); resp.Code() != "NETWORK_ERROR" { + t.Fatalf("code = %q for %q", resp.Code(), msg) } } } @@ -111,7 +111,7 @@ func TestGlobalJSONErrorOperationTimeoutIsNotNetwork(t *testing.T) { "query timeout exceeded for SELECT", } for _, msg := range operationTimeouts { - if resp := GlobalJSONError(errors.New(msg)); resp.Code == "NETWORK_ERROR" { + if resp := GlobalJSONError(errors.New(msg)); resp.Code() == "NETWORK_ERROR" { t.Fatalf("misclassified %q as NETWORK_ERROR", msg) } } @@ -122,8 +122,8 @@ func TestGlobalJSONErrorServiceToken(t *testing.T) { if resp.Status != "action_required" { t.Fatalf("status = %q", resp.Status) } - if resp.Code != "SERVICE_TOKEN_INVALID" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "SERVICE_TOKEN_INVALID" { + t.Fatalf("code = %q", resp.Code()) } } @@ -132,14 +132,39 @@ func TestGlobalJSONErrorGenericFallback(t *testing.T) { if resp.Status != "error" { t.Fatalf("status = %q", resp.Status) } - if resp.Code != "COMMAND_FAILED" { - t.Fatalf("code = %q", resp.Code) + if resp.Code() != "COMMAND_FAILED" { + t.Fatalf("code = %q", resp.Code()) } if len(resp.NextSteps) != 2 || resp.NextSteps[0] != AgentAuthCheckCmd() { t.Fatalf("next_steps = %#v", resp.NextSteps) } } +func TestGlobalJSONErrorIssuesMirrorError(t *testing.T) { + resp := GlobalJSONError(errors.New("something went wrong")) + if len(resp.Issues) != 1 { + t.Fatalf("issues = %#v", resp.Issues) + } + if resp.Issues[0].Message != resp.Error { + t.Fatalf("issue message %q != error %q", resp.Issues[0].Message, resp.Error) + } +} + +func TestGlobalJSONErrorStripsANSI(t *testing.T) { + colored := "database \x1b[1;34mfoo\x1b[0m does not exist in organization \x1b[1;34mbar\x1b[0m" + resp := GlobalJSONError(errors.New(colored)) + want := "database foo does not exist in organization bar" + if resp.Error != want { + t.Fatalf("error = %q, want %q", resp.Error, want) + } + if resp.Issues[0].Message != want { + t.Fatalf("issue message = %q, want %q", resp.Issues[0].Message, want) + } + if resp.Code() != "NOT_FOUND" { + t.Fatalf("code = %q", resp.Code()) + } +} + func TestGlobalJSONErrorUsesCmdErrorMessage(t *testing.T) { resp := GlobalJSONError(&Error{ Msg: "You are not authenticated.", @@ -153,6 +178,21 @@ func TestGlobalJSONErrorUsesCmdErrorMessage(t *testing.T) { } } +func TestGlobalJSONErrorKeepsActionRequiredForUnclassified(t *testing.T) { + // An action-required exit code must survive classification even when the + // message matches no known pattern. + resp := GlobalJSONError(&Error{ + Msg: "operator approval pending", + ExitCode: ActionRequestedExitCode, + }) + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Code() != "COMMAND_FAILED" { + t.Fatalf("code = %q", resp.Code()) + } +} + func TestReportGlobalJSONError(t *testing.T) { var out, errOut bytes.Buffer exit := ReportGlobalJSONError(&out, &errOut, errors.New("boom")) @@ -170,7 +210,7 @@ func TestReportGlobalJSONError(t *testing.T) { if resp.Status != "error" { t.Fatalf("status = %q", resp.Status) } - if resp.Error == "" || len(resp.NextSteps) == 0 { + if resp.Error == "" || len(resp.Issues) != 1 || len(resp.NextSteps) == 0 { t.Fatalf("resp = %#v", resp) } } From 008f9fee62f18e4247c1ef74dc8f3a317a672df3 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 8 Jul 2026 15:05:15 -0500 Subject: [PATCH 7/9] Match both interactive-shell and interactive-terminal messages as TTY_REQUIRED. Co-authored-by: Cursor --- internal/cmdutil/json_error.go | 12 +++++++++--- internal/cmdutil/json_error_test.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/cmdutil/json_error.go b/internal/cmdutil/json_error.go index 1f40ca084..5873a2c09 100644 --- a/internal/cmdutil/json_error.go +++ b/internal/cmdutil/json_error.go @@ -116,11 +116,17 @@ func GlobalJSONError(err error) JSONErrorResponse { AgentGuideCmd(), } - // TTY-gated commands: JSON mode is the non-interactive path. - case strings.Contains(msg, "requires an interactive shell"): + // TTY-gated commands: messages vary ("interactive shell", "interactive + // terminal"), so match the shared prefix. Only login has a JSON-mode + // alternative worth suggesting. + case strings.Contains(msg, "requires an interactive"): status = "action_required" code = "TTY_REQUIRED" - nextSteps = []string{AgentAuthLoginCmd()} + if strings.Contains(lower, "login") { + nextSteps = []string{AgentAuthLoginCmd()} + } else { + nextSteps = []string{"Re-run this command in an interactive terminal"} + } // Confirmation-gated commands: the fix is user approval, then --force. case strings.Contains(msg, "run with --force"): diff --git a/internal/cmdutil/json_error_test.go b/internal/cmdutil/json_error_test.go index 16c515de7..5e84d91df 100644 --- a/internal/cmdutil/json_error_test.go +++ b/internal/cmdutil/json_error_test.go @@ -165,6 +165,26 @@ func TestGlobalJSONErrorStripsANSI(t *testing.T) { } } +func TestGlobalJSONErrorTTYRequired(t *testing.T) { + login := GlobalJSONError(errors.New("the 'login' command requires an interactive shell (use --format json; browser opens when possible, then polls until approved)")) + if login.Status != "action_required" || login.Code() != "TTY_REQUIRED" { + t.Fatalf("login: status = %q, code = %q", login.Status, login.Code()) + } + if len(login.NextSteps) == 0 || login.NextSteps[0] != AgentAuthLoginCmd() { + t.Fatalf("login next_steps = %#v", login.NextSteps) + } + + replay := GlobalJSONError(errors.New("--replay requires an interactive terminal")) + if replay.Status != "action_required" || replay.Code() != "TTY_REQUIRED" { + t.Fatalf("replay: status = %q, code = %q", replay.Status, replay.Code()) + } + for _, step := range replay.NextSteps { + if step == AgentAuthLoginCmd() { + t.Fatalf("replay should not suggest auth login, got %#v", replay.NextSteps) + } + } +} + func TestGlobalJSONErrorUsesCmdErrorMessage(t *testing.T) { resp := GlobalJSONError(&Error{ Msg: "You are not authenticated.", From 8220ab3d847f36dc7a1b5b1ec3e1bd3530ac37d6 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 8 Jul 2026 15:21:13 -0500 Subject: [PATCH 8/9] Error on unknown root subcommands so JSON mode reports UNKNOWN_COMMAND. Co-authored-by: Cursor --- internal/cmd/root.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 279463175..3b2b54b30 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -82,6 +82,15 @@ Agents and automation should start with: pscale agent-guide --format json pscale auth check --format json`, TraverseChildren: true, + // A non-runnable root returns help (exit 0) before args validation, so an + // unknown subcommand like "pscale wat" never errors and JSON mode prints + // help text instead of an UNKNOWN_COMMAND envelope. A RunE makes the root + // runnable, which lets Args reject unknown subcommands; bare "pscale" + // keeps showing help. + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, } // Execute executes the command and returns the exit status of the finished From 838f2a7a53e95852c350cdf84e341de6b729f74e Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 8 Jul 2026 15:30:49 -0500 Subject: [PATCH 9/9] Revert "Error on unknown root subcommands so JSON mode reports UNKNOWN_COMMAND." This reverts commit 8220ab3d847f36dc7a1b5b1ec3e1bd3530ac37d6. --- internal/cmd/root.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 3b2b54b30..279463175 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -82,15 +82,6 @@ Agents and automation should start with: pscale agent-guide --format json pscale auth check --format json`, TraverseChildren: true, - // A non-runnable root returns help (exit 0) before args validation, so an - // unknown subcommand like "pscale wat" never errors and JSON mode prints - // help text instead of an UNKNOWN_COMMAND envelope. A RunE makes the root - // runnable, which lets Args reject unknown subcommands; bare "pscale" - // keeps showing help. - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - return cmd.Help() - }, } // Execute executes the command and returns the exit status of the finished