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
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 88 additions & 0 deletions cmd/output.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cmd

import (
"errors"
"fmt"
"strings"

"github.com/dropbox/dbxcli/internal/output"
"github.com/spf13/cobra"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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"
}
}
Loading