From 25c14229f1f766843504b5f37178339c16bac6b6 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Mon, 22 Jun 2026 13:55:35 -0700 Subject: [PATCH] Add JSON error responses for --output=json mode Render errors as structured JSON to stdout when --output=json is requested. Uses pre-parse arg scanning to detect JSON mode for errors that occur before Cobra resolves the command (unknown command/flag). Adds stable error codes for scripting consumers. --- README.md | 60 ++++++++++++- cmd/output.go | 88 +++++++++++++++++++ cmd/output_test.go | 205 +++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 6 +- 4 files changed, 356 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 38743d4..72e9445 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,65 @@ $ dbxcli rm --output=json /old-file.txt $ dbxcli restore --output=json /Reports/old.pdf 015f... ``` -JSON support is rolling out command by command. Commands that have not been migrated return `structured output is not supported for this command yet` when used with `--output=json`. +JSON support is rolling out command by command. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`. + +Command results are written to stdout. Status, progress, warnings, diagnostics, and verbose logs are written to stderr. + +Successful JSON responses are command-specific. Commands that operate on one path usually return an `input` object and a `result` metadata object: + +```json +{ + "input": { + "path": "/new-folder", + "parents": false + }, + "result": { + "type": "folder", + "path_display": "/new-folder", + "path_lower": "/new-folder", + "id": "id:..." + } +} +``` + +Commands that operate on multiple paths return a `results` array: + +```json +{ + "results": [ + { + "input": { + "path": "/old-file.txt", + "permanent": false, + "recursive": false, + "force": false + }, + "result": { + "type": "file", + "path_display": "/old-file.txt", + "path_lower": "/old-file.txt", + "id": "id:...", + "rev": "...", + "size": 123 + } + } + ] +} +``` + +In JSON mode, command errors are also written to stdout. The process still exits with a non-zero status: + +```json +{ + "ok": false, + "error": { + "message": "path exists and is not a folder: /old-file.txt", + "code": "path_conflict" + } +} +``` -Command results are written to stdout. Status, progress, warnings, diagnostics, errors, and verbose logs are written to stderr. JSON errors are not wrapped in a JSON response object. +Error `code` values are stable identifiers intended for scripts. Current codes are `structured_output_unsupported`, `unsupported_output_format`, `unknown_command`, `unknown_flag`, `path_conflict`, `invalid_arguments`, and `command_failed`. ### Authentication diff --git a/cmd/output.go b/cmd/output.go index 3eb586c..84c9ffd 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -1,7 +1,9 @@ package cmd import ( + "errors" "fmt" + "strings" "github.com/dropbox/dbxcli/internal/output" "github.com/spf13/cobra" @@ -12,6 +14,16 @@ const ( structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput" ) +type jsonErrorResponse struct { + OK bool `json:"ok"` + Error jsonError `json:"error"` +} + +type jsonError struct { + Message string `json:"message"` + Code string `json:"code"` +} + func commandOutput(cmd *cobra.Command) *output.Renderer { if cmd == nil { return output.New(nil, nil, output.FormatText) @@ -45,6 +57,10 @@ func commandOutputFlagValue(cmd *cobra.Command) string { if err == nil { return value } + value, err = cmd.PersistentFlags().GetString(outputFlag) + if err == nil { + return value + } return string(output.FormatText) } @@ -102,3 +118,75 @@ func commandVerboseStatus(cmd *cobra.Command, format string, args ...any) { commandOutput(cmd).Status(format, args...) } } + +func renderCommandError(cmd *cobra.Command, err error) { + renderCommandErrorWithJSON(cmd, err, false) +} + +func renderCommandErrorWithJSON(cmd *cobra.Command, err error, forceJSON bool) { + if err == nil { + return + } + if cmd == nil { + cmd = RootCmd + } + + if forceJSON || commandOutputFormat(cmd) == output.FormatJSON { + renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, jsonErrorResponse{ + OK: false, + Error: jsonError{ + Message: err.Error(), + Code: jsonErrorCode(err), + }, + }) + if renderErr == nil { + return + } + err = renderErr + } + + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", err) + if jsonErrorCode(err) == "unknown_command" { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Run '%s --help' for usage.\n", cmd.CommandPath()) + } +} + +// outputJSONRequested is a narrow pre-parse fallback for errors that happen +// before Cobra has resolved a command/flag context, such as unknown commands. +func outputJSONRequested(args []string) bool { + for i := 0; i < len(args); i++ { + switch args[i] { + case "--": + return false + case "--output=json": + return true + case "--output": + return i+1 < len(args) && args[i+1] == "json" + } + } + return false +} + +// jsonErrorCode derives stable script-facing codes from existing CLI errors. +// If a matched error message changes, update this mapping with it. +func jsonErrorCode(err error) string { + if errors.Is(err, output.ErrStructuredOutputUnsupported) { + return "structured_output_unsupported" + } + + message := err.Error() + switch { + case strings.Contains(message, "unsupported output format"): + return "unsupported_output_format" + case strings.Contains(message, "unknown command"): + return "unknown_command" + case strings.Contains(message, "unknown flag"): + return "unknown_flag" + case strings.Contains(message, "path exists and is not a folder"): + return "path_conflict" + case strings.Contains(message, "requires a"): + return "invalid_arguments" + default: + return "command_failed" + } +} diff --git a/cmd/output_test.go b/cmd/output_test.go index a6facae..07fc40c 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -2,7 +2,9 @@ package cmd import ( "bytes" + "encoding/json" "errors" + "fmt" "io" "strings" "testing" @@ -77,6 +79,28 @@ func TestCommandOutputHonorsInheritedOutputJSON(t *testing.T) { } } +func TestCommandOutputHonorsRootPersistentOutputJSON(t *testing.T) { + var stdout bytes.Buffer + root := &cobra.Command{} + root.SetOut(&stdout) + root.PersistentFlags().String(outputFlag, "json", "") + + out := commandOutput(root) + err := out.Render(func(w io.Writer) error { + t.Fatal("text renderer should not be called for JSON output") + return nil + }, struct { + Status string `json:"status"` + }{Status: "ok"}) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + + if got, want := stdout.String(), "{\"status\":\"ok\"}\n"; got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + func TestValidateOutputFormatRejectsInvalidValue(t *testing.T) { cmd := &cobra.Command{} cmd.Flags().String(outputFlag, "yaml", "") @@ -182,3 +206,184 @@ func TestCommandVerboseStatusWritesOnlyWhenVerbose(t *testing.T) { t.Fatalf("stderr = %q, want %q", got, want) } } + +func TestRenderCommandErrorWritesTextErrorToStderr(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "text", "") + + renderCommandError(cmd, errors.New("failed")) + + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty", got) + } + if got, want := stderr.String(), "Error: failed\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestRenderCommandErrorTextUnknownCommandIncludesUsageHint(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{Use: "dbxcli"} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "text", "") + + renderCommandError(cmd, errors.New(`unknown command "missing" for "dbxcli"`)) + + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty", got) + } + want := "Error: unknown command \"missing\" for \"dbxcli\"\nRun 'dbxcli --help' for usage.\n" + if got := stderr.String(); got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "json", "") + + renderCommandError(cmd, errors.New("failed")) + + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + got := decodeJSONErrorResponse(t, stdout.String()) + if got.OK { + t.Fatalf("ok = true, want false") + } + if got.Error.Message != "failed" { + t.Fatalf("message = %q, want failed", got.Error.Message) + } + if got.Error.Code != "command_failed" { + t.Fatalf("code = %q, want command_failed", got.Error.Code) + } +} + +func TestRenderCommandErrorWritesUnsupportedStructuredOutputAsJSON(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "json", "") + + renderCommandError(cmd, output.ErrStructuredOutputUnsupported) + + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + got := decodeJSONErrorResponse(t, stdout.String()) + if got.Error.Code != "structured_output_unsupported" { + t.Fatalf("code = %q, want structured_output_unsupported", got.Error.Code) + } + if !strings.Contains(got.Error.Message, "structured output is not supported") { + t.Fatalf("message = %q, want structured output error", got.Error.Message) + } +} + +func TestRenderCommandErrorWithJSONForcesJSONError(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "text", "") + + renderCommandErrorWithJSON(cmd, errors.New(`unknown command "missing" for "dbxcli"`), true) + + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + got := decodeJSONErrorResponse(t, stdout.String()) + if got.Error.Code != "unknown_command" { + t.Fatalf("code = %q, want unknown_command", got.Error.Code) + } +} + +func TestRenderCommandErrorInvalidOutputFormatFallsBackToText(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.Flags().String(outputFlag, "yaml", "") + + err := fmt.Errorf(`unsupported output format "yaml": use text or json`) + renderCommandError(cmd, err) + + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty", got) + } + if got, want := stderr.String(), "Error: unsupported output format \"yaml\": use text or json\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } +} + +func TestOutputJSONRequested(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + { + name: "equals", + args: []string{"--output=json", "missing"}, + want: true, + }, + { + name: "separate", + args: []string{"--output", "json", "missing"}, + want: true, + }, + { + name: "text", + args: []string{"--output=text", "missing"}, + want: false, + }, + { + name: "invalid format", + args: []string{"--output", "yaml", "missing"}, + want: false, + }, + { + name: "after double dash", + args: []string{"mkdir", "--", "--output=json"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := outputJSONRequested(tt.args); got != tt.want { + t.Fatalf("outputJSONRequested(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + +func TestJSONErrorCodePathConflict(t *testing.T) { + err := errors.New("path exists and is not a folder: /file") + if got, want := jsonErrorCode(err), "path_conflict"; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) + } +} + +func decodeJSONErrorResponse(t *testing.T, value string) jsonErrorResponse { + t.Helper() + + var got jsonErrorResponse + if err := json.Unmarshal([]byte(value), &got); err != nil { + t.Fatalf("decode JSON error response: %v\noutput: %s", err, value) + } + return got +} diff --git a/cmd/root.go b/cmd/root.go index af73018..21c1298 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -178,13 +178,17 @@ var RootCmd = &cobra.Command{ Long: `Use dbxcli to quickly interact with your Dropbox, upload/download files, manage your team and more. It is easy, scriptable and works on all platforms!`, SilenceUsage: true, + SilenceErrors: true, PersistentPreRunE: initDbx, } // Execute adds all child commands to the root command sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - if err := RootCmd.Execute(); err != nil { + jsonErrorOutput := outputJSONRequested(os.Args[1:]) + cmd, err := RootCmd.ExecuteC() + if err != nil { + renderCommandErrorWithJSON(cmd, err, jsonErrorOutput) os.Exit(1) } }