Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,42 @@ pscale --org <org> database list --format json
| `--org <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 <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.
Expand Down Expand Up @@ -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).

Expand Down
59 changes: 9 additions & 50 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ limitations under the License.
package cmd

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
Expand Down Expand Up @@ -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 <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
}

Expand All @@ -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 <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("<org>"),
},
}
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 {
Expand Down
6 changes: 6 additions & 0 deletions internal/cmd/sql/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
204 changes: 204 additions & 0 deletions internal/cmdutil/json_error.go
Original file line number Diff line number Diff line change
@@ -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(),
}
Comment thread
cursor[bot] marked this conversation as resolved.

// 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)
}
Loading