diff --git a/AGENTS.md b/AGENTS.md index b21e8e8d..19974e8d 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/root.go b/internal/cmd/root.go index 04538e2e..27946317 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" @@ -128,28 +126,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 } @@ -163,43 +159,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/sql/json.go b/internal/cmd/sql/json.go index a0ace00e..12faad0b 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 new file mode 100644 index 00000000..5873a2c0 --- /dev/null +++ b/internal/cmdutil/json_error.go @@ -0,0 +1,204 @@ +package cmdutil + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" +) + +// 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"` + 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: +// 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" + } + } + + msg = ansiEscape.ReplaceAllString(msg, "") + + 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: 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" + 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"): + 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. + // 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, "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", + AgentAuthCheckCmd(), + } + + // Unclassified: auth state is the one thing worth ruling out first. + default: + nextSteps = []string{ + AgentAuthCheckCmd(), + AgentGuideCmd(), + } + } + + return JSONErrorResponse{ + Status: status, + Error: msg, + Issues: []JSONErrorIssue{{Code: code, Message: 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 00000000..5e84d91d --- /dev/null +++ b/internal/cmdutil/json_error_test.go @@ -0,0 +1,247 @@ +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) { + 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) + } + } +} + +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 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 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.", + 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 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")) + 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.Issues) != 1 || 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) + } +}