Skip to content

Commit b0914f1

Browse files
Merge pull request #1282 from planetscale/cli/workflow-json-next-steps
Add a global JSON error envelope with stable codes and next_steps
2 parents f41eeab + 838f2a7 commit b0914f1

5 files changed

Lines changed: 503 additions & 51 deletions

File tree

AGENTS.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,42 @@ pscale --org <org> database list --format json
9393
| `--org <org>` | Organization (on resource subcommands only) |
9494
| `--api-url` | Non-production API base URL — pass on every command when not using production |
9595

96+
## JSON errors
97+
98+
With `--format json`, any command that fails prints exactly one JSON envelope on **stdout**:
99+
100+
```json
101+
{
102+
"status": "error",
103+
"error": "human-readable message",
104+
"issues": [{ "code": "NOT_FOUND", "message": "human-readable message" }],
105+
"next_steps": ["pscale org list --format json", "pscale database list --org <org> --format json"]
106+
}
107+
```
108+
109+
- `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`.
110+
- `issues[].code` is stable and machine-readable; branch on it, not on message text.
111+
- `next_steps` are concrete commands or instructions, ordered by likelihood.
112+
113+
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.
114+
115+
| Code | Meaning |
116+
|------|---------|
117+
| `NO_AUTH` | Not authenticated or token expired; run `pscale auth login --format json` |
118+
| `AUTH_INVALID` | Stored credentials rejected by the API; log in again |
119+
| `SERVICE_TOKEN_INVALID` | Service token id/secret rejected; verify the values |
120+
| `NO_ORG` | Authenticated but no organization configured |
121+
| `INVALID_FLAG_PLACEMENT` | `--org` was passed on `pscale` root; move it to the subcommand |
122+
| `INVALID_USAGE` | Missing arguments or required flags; the message names them |
123+
| `UNKNOWN_COMMAND` | Command does not exist; check `pscale --help` |
124+
| `UNKNOWN_FLAG` | Flag does not exist on this command; check `--help` |
125+
| `TTY_REQUIRED` | Command needs an interactive terminal; use the JSON alternative in `next_steps` |
126+
| `CONFIRMATION_REQUIRED` | Destructive or gated action; ask the user, then re-run with `--force` |
127+
| `DESTRUCTIVE_SQL` | Query would delete data or schema; ask the user, then re-run with `--force` |
128+
| `NOT_FOUND` | Org, database, branch, or resource does not exist; run the discovery commands |
129+
| `NETWORK_ERROR` | Transport-level failure; check connectivity and `--api-url`, then retry |
130+
| `COMMAND_FAILED` | Unclassified failure; read `error` and rule out auth first |
131+
96132
## Authentication
97133

98134
`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
153189

154190
MySQL may return synthetic column names (e.g. `:vtg1 /* INT64 */`). PostgreSQL may use names like `?column?`.
155191

156-
Error: one JSON object on stdout with `status: "error"`, `error`, and `next_steps`.
192+
Error: one JSON object on stdout with `status: "error"`, `error`, `issues`, and `next_steps` (see JSON errors above).
157193

158194
Destructive SQL without `--force`: `status: "action_required"`, `query_kind: "destructive"`, `issues`, and `next_steps` (includes `--force` retry command).
159195

internal/cmd/root.go

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ limitations under the License.
1616
package cmd
1717

1818
import (
19-
"bytes"
2019
"context"
21-
"encoding/json"
2220
"errors"
2321
"fmt"
2422
"io/fs"
@@ -128,28 +126,26 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver,
128126
return 0
129127
}
130128

131-
var cmdErr *cmdutil.Error
132-
if errors.As(err, &cmdErr) && cmdErr.Handled {
129+
if cmdErr, ok := errors.AsType[*cmdutil.Error](err); ok && cmdErr.Handled {
133130
if cmdErr.ExitCode != 0 {
134131
return cmdErr.ExitCode
135132
}
136133
return cmdutil.ActionRequestedExitCode
137134
}
138135

139-
if isRootOrgFlagError(err) {
140-
return printRootOrgFlagError(format, err)
136+
if format == printer.JSON {
137+
return cmdutil.ReportGlobalJSONError(os.Stdout, os.Stderr, err)
141138
}
142139

143-
// print any user specific messages first
144-
switch format {
145-
case printer.JSON:
146-
fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err)
147-
default:
148-
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
140+
if isRootOrgFlagError(err) {
141+
fmt.Fprintf(os.Stderr, "Error: %s\nHint: --org belongs on resource commands, not on pscale root. Example: pscale database list --org <org> --format json\n", err)
142+
return cmdutil.FatalErrExitCode
149143
}
150144

145+
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
146+
151147
// check if a sub command wants to return a specific exit code
152-
if errors.As(err, &cmdErr) {
148+
if cmdErr, ok := errors.AsType[*cmdutil.Error](err); ok {
153149
return cmdErr.ExitCode
154150
}
155151

@@ -163,43 +159,6 @@ func isRootOrgFlagError(err error) bool {
163159
return strings.Contains(err.Error(), "unknown flag: --org")
164160
}
165161

166-
func printRootOrgFlagError(format printer.Format, err error) int {
167-
const hint = "--org belongs on resource commands, not on pscale root. Example: pscale database list --org <org> --format json"
168-
169-
if format == printer.JSON {
170-
resp := struct {
171-
Status string `json:"status"`
172-
Code string `json:"code"`
173-
Error string `json:"error"`
174-
Hint string `json:"hint"`
175-
NextSteps []string `json:"next_steps"`
176-
}{
177-
Status: "error",
178-
Code: "INVALID_FLAG_PLACEMENT",
179-
Error: err.Error(),
180-
Hint: hint,
181-
NextSteps: []string{
182-
cmdutil.AgentGuideCmd(),
183-
cmdutil.AgentAuthCheckCmd(),
184-
cmdutil.AgentDatabaseListCmd("<org>"),
185-
},
186-
}
187-
var buf bytes.Buffer
188-
enc := json.NewEncoder(&buf)
189-
enc.SetEscapeHTML(false)
190-
enc.SetIndent("", " ")
191-
if marshalErr := enc.Encode(resp); marshalErr != nil {
192-
fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err)
193-
return cmdutil.FatalErrExitCode
194-
}
195-
fmt.Fprint(os.Stdout, buf.String())
196-
return cmdutil.FatalErrExitCode
197-
}
198-
199-
fmt.Fprintf(os.Stderr, "Error: %s\nHint: %s\n", err, hint)
200-
return cmdutil.FatalErrExitCode
201-
}
202-
203162
// runCmd adds all child commands to the root command, sets flags
204163
// appropriately, and runs the root command.
205164
func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer.Format, debug *bool, sigc chan os.Signal, signals []os.Signal) error {

internal/cmd/sql/json.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ func handleExecuteError(ch *cmdutil.Helper, err error, database, branch string)
4242
return reportJSON(ch, map[string]any{
4343
"status": "error",
4444
"error": err.Error(),
45+
"issues": []map[string]string{
46+
{
47+
"code": "COMMAND_FAILED",
48+
"message": err.Error(),
49+
},
50+
},
4551
"next_steps": []string{
4652
cmdutil.AgentAuthCheckCmd(),
4753
cmdutil.AgentSQLCmd(ch.Config.Organization, database, branch, false),

internal/cmdutil/json_error.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package cmdutil
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"regexp"
9+
"strings"
10+
)
11+
12+
// JSONErrorIssue mirrors the issue shape used by auth check, sql, and import
13+
// d1 responses: a stable machine-readable code plus the human message.
14+
type JSONErrorIssue struct {
15+
Code string `json:"code"`
16+
Message string `json:"message"`
17+
}
18+
19+
// JSONErrorResponse is the fallback JSON envelope for unhandled command
20+
// errors. It follows the same schema as command-specific error responses:
21+
// status, error, issues (code + message), next_steps.
22+
type JSONErrorResponse struct {
23+
Status string `json:"status"`
24+
Error string `json:"error"`
25+
Issues []JSONErrorIssue `json:"issues"`
26+
NextSteps []string `json:"next_steps"`
27+
}
28+
29+
// Code returns the classification code of the first issue.
30+
func (r JSONErrorResponse) Code() string {
31+
if len(r.Issues) == 0 {
32+
return ""
33+
}
34+
return r.Issues[0].Code
35+
}
36+
37+
// ansiEscape matches CSI color/style sequences. Error messages built for
38+
// human output may embed them (e.g. via printer.BoldBlue); they must never
39+
// reach a JSON payload.
40+
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
41+
42+
// GlobalJSONError classifies an error for the global JSON envelope.
43+
//
44+
// Classification is by message text because this is the top-level fallback:
45+
// command-specific handlers with typed errors have already had their chance.
46+
// next_steps must earn their place: each case lists only the commands that
47+
// actually address the failure, not generic bootstrap commands.
48+
func GlobalJSONError(err error) JSONErrorResponse {
49+
msg := err.Error()
50+
status := "error"
51+
code := "COMMAND_FAILED"
52+
53+
if cmdErr, ok := errors.AsType[*Error](err); ok {
54+
if cmdErr.Msg != "" {
55+
msg = cmdErr.Msg
56+
}
57+
if cmdErr.ExitCode == ActionRequestedExitCode {
58+
status = "action_required"
59+
}
60+
}
61+
62+
msg = ansiEscape.ReplaceAllString(msg, "")
63+
64+
var nextSteps []string
65+
lower := strings.ToLower(msg)
66+
67+
switch {
68+
// Auth problems: the fix is logging in (or fixing credentials), then verifying.
69+
case strings.Contains(msg, WarnAuthMessage) ||
70+
strings.Contains(lower, "not authenticated") ||
71+
strings.Contains(msg, "access token has expired"):
72+
status = "action_required"
73+
code = "NO_AUTH"
74+
nextSteps = []string{AgentAuthLoginCmd(), AgentAuthCheckCmd()}
75+
76+
case strings.Contains(msg, "Authentication failed") && strings.Contains(msg, "service token"):
77+
status = "action_required"
78+
code = "SERVICE_TOKEN_INVALID"
79+
nextSteps = []string{
80+
"Verify --service-token-id and --service-token values (or PLANETSCALE_SERVICE_TOKEN_ID / PLANETSCALE_SERVICE_TOKEN)",
81+
AgentAuthCheckCmd(),
82+
}
83+
84+
// --org on pscale root: show the corrected shape, not a re-auth loop.
85+
case strings.Contains(msg, "unknown flag: --org"):
86+
code = "INVALID_FLAG_PLACEMENT"
87+
nextSteps = []string{
88+
"Move --org onto the resource subcommand",
89+
AgentDatabaseListCmd(""),
90+
}
91+
92+
// Wrong invocation: cobra's message already names the missing piece and
93+
// includes usage. Point at the guide for command shapes; auth is not the issue.
94+
case strings.Contains(msg, "missing argument") ||
95+
strings.Contains(msg, "missing arguments") ||
96+
strings.Contains(msg, "missing required flags") ||
97+
strings.Contains(msg, "required flag"):
98+
status = "action_required"
99+
code = "INVALID_USAGE"
100+
nextSteps = []string{
101+
"Re-run with the missing arguments or flags named in the error message",
102+
AgentGuideCmd(),
103+
}
104+
105+
case strings.Contains(msg, "unknown command"):
106+
code = "UNKNOWN_COMMAND"
107+
nextSteps = []string{
108+
"Run `pscale --help` to list valid commands",
109+
AgentGuideCmd(),
110+
}
111+
112+
case strings.Contains(msg, "unknown flag") || strings.Contains(msg, "unknown shorthand flag"):
113+
code = "UNKNOWN_FLAG"
114+
nextSteps = []string{
115+
"Run the same command with --help to list valid flags",
116+
AgentGuideCmd(),
117+
}
118+
119+
// TTY-gated commands: messages vary ("interactive shell", "interactive
120+
// terminal"), so match the shared prefix. Only login has a JSON-mode
121+
// alternative worth suggesting.
122+
case strings.Contains(msg, "requires an interactive"):
123+
status = "action_required"
124+
code = "TTY_REQUIRED"
125+
if strings.Contains(lower, "login") {
126+
nextSteps = []string{AgentAuthLoginCmd()}
127+
} else {
128+
nextSteps = []string{"Re-run this command in an interactive terminal"}
129+
}
130+
131+
// Confirmation-gated commands: the fix is user approval, then --force.
132+
case strings.Contains(msg, "run with --force"):
133+
status = "action_required"
134+
code = "CONFIRMATION_REQUIRED"
135+
nextSteps = []string{
136+
"Ask the user to approve this action",
137+
"Re-run the same command with --force after approval",
138+
}
139+
140+
// Resource lookups that failed: discovery commands are the way forward.
141+
case strings.Contains(msg, "does not exist"):
142+
code = "NOT_FOUND"
143+
nextSteps = []string{
144+
AgentOrgListCmd(),
145+
AgentDatabaseListCmd(""),
146+
}
147+
148+
// Connectivity: nothing an agent can self-serve beyond retry and status.
149+
// Match transport-level failures only — a bare "timeout" substring would
150+
// swallow operation or deadline timeouts that deserve command-specific
151+
// handling.
152+
case strings.Contains(lower, "connection refused") ||
153+
strings.Contains(lower, "connection reset by peer") ||
154+
strings.Contains(lower, "no such host") ||
155+
strings.Contains(lower, "i/o timeout") ||
156+
strings.Contains(lower, "tls handshake timeout") ||
157+
strings.Contains(lower, "temporary failure in name resolution"):
158+
code = "NETWORK_ERROR"
159+
nextSteps = []string{
160+
"Check network connectivity and --api-url, then retry",
161+
AgentAuthCheckCmd(),
162+
}
163+
164+
// Unclassified: auth state is the one thing worth ruling out first.
165+
default:
166+
nextSteps = []string{
167+
AgentAuthCheckCmd(),
168+
AgentGuideCmd(),
169+
}
170+
}
171+
172+
return JSONErrorResponse{
173+
Status: status,
174+
Error: msg,
175+
Issues: []JSONErrorIssue{{Code: code, Message: msg}},
176+
NextSteps: nextSteps,
177+
}
178+
}
179+
180+
// ReportGlobalJSONError writes the classified envelope for err to w and
181+
// returns the process exit code. On encoding failure it falls back to a
182+
// plain error line on errW.
183+
func ReportGlobalJSONError(w, errW io.Writer, err error) int {
184+
resp := GlobalJSONError(err)
185+
if writeErr := WriteJSONError(w, resp); writeErr != nil {
186+
fmt.Fprintf(errW, "Error: %s\n", err)
187+
}
188+
189+
if resp.Status == "action_required" {
190+
return ActionRequestedExitCode
191+
}
192+
if cmdErr, ok := errors.AsType[*Error](err); ok && cmdErr.ExitCode != 0 {
193+
return cmdErr.ExitCode
194+
}
195+
return FatalErrExitCode
196+
}
197+
198+
// WriteJSONError writes a JSON error envelope to w.
199+
func WriteJSONError(w io.Writer, resp JSONErrorResponse) error {
200+
enc := json.NewEncoder(w)
201+
enc.SetEscapeHTML(false)
202+
enc.SetIndent("", " ")
203+
return enc.Encode(resp)
204+
}

0 commit comments

Comments
 (0)