From e9561211735cb5c8f620b0eddfc98f09f4049d3c Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Sun, 28 Jun 2026 09:17:24 -0700 Subject: [PATCH] Bump module path to v3 and add stable exit codes Migrate module path to github.com/dropbox/dbxcli/v3 following Go major-version conventions. Add deterministic exit-code mapping from JSON error codes (0-8) so shell scripts and CI can branch on exit status without parsing JSON. Add partial_transfer error code for stdout download failures after partial output. --- README.md | 4 +- cmd/cp.go | 2 +- cmd/cp_test.go | 2 +- cmd/get.go | 2 +- cmd/help_json.go | 2 +- cmd/json_contract_test.go | 3 +- cmd/logout.go | 2 +- cmd/ls.go | 2 +- cmd/mv.go | 2 +- cmd/output.go | 53 ++++++++++- cmd/output_test.go | 122 ++++++++++++++++++++++++- cmd/put.go | 2 +- cmd/revs.go | 2 +- cmd/root.go | 2 +- cmd/root_test.go | 126 +++++++++++++++++++++++++- cmd/search.go | 2 +- cmd/share_link_download.go | 2 +- cmd/share_link_update.go | 2 +- cmd/stdout.go | 18 +++- docs/automation.md | 26 ++++-- docs/json-schema/v1/README.md | 1 + docs/json-schema/v1/error.schema.json | 1 + go.mod | 4 +- main.go | 2 +- tools/gen-docs/main.go | 2 +- 25 files changed, 355 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e14ba84..6130d61 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # `dbxcli`: Dropbox from the command line [![CI](https://github.com/dropbox/dbxcli/actions/workflows/ci.yml/badge.svg)](https://github.com/dropbox/dbxcli/actions/workflows/ci.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/dropbox/dbxcli)](https://goreportcard.com/report/github.com/dropbox/dbxcli) +[![Go Report Card](https://goreportcard.com/badge/github.com/dropbox/dbxcli/v3)](https://goreportcard.com/report/github.com/dropbox/dbxcli/v3) `dbxcli` is a scriptable Dropbox CLI for files, shared links, teams, and automation workflows. It is built for humans at the terminal, scripts, CI jobs, @@ -126,7 +126,7 @@ Release assets include: ### Build from source ```sh -go install github.com/dropbox/dbxcli@latest +go install github.com/dropbox/dbxcli/v3@latest ``` Or build from a clone: diff --git a/cmd/cp.go b/cmd/cp.go index c92ede9..e90a42b 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -18,7 +18,7 @@ import ( "fmt" "strings" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) diff --git a/cmd/cp_test.go b/cmd/cp_test.go index 29727c8..43e59e7 100644 --- a/cmd/cp_test.go +++ b/cmd/cp_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" diff --git a/cmd/get.go b/cmd/get.go index b8c5081..72dc3e8 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -24,7 +24,7 @@ import ( "strings" "time" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dustin/go-humanize" "github.com/mitchellh/ioprogress" diff --git a/cmd/help_json.go b/cmd/help_json.go index 15ef18b..ade4710 100644 --- a/cmd/help_json.go +++ b/cmd/help_json.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/spf13/cobra" "github.com/spf13/pflag" ) diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 57d1c1e..87b68dc 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/spf13/cobra" ) @@ -519,6 +519,7 @@ func expectedJSONErrorCodes() []string { jsonErrorCodeEnvTokenStillActive, jsonErrorCodeInvalidArguments, jsonErrorCodeNotFound, + jsonErrorCodePartialTransfer, jsonErrorCodePathConflict, jsonErrorCodePermissionDenied, jsonErrorCodeRateLimited, diff --git a/cmd/logout.go b/cmd/logout.go index 78de36a..ef2ff0e 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -19,7 +19,7 @@ import ( "os" "sort" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" "github.com/spf13/cobra" diff --git a/cmd/ls.go b/cmd/ls.go index c512729..cb859f9 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -21,7 +21,7 @@ import ( "strings" "text/tabwriter" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dustin/go-humanize" "github.com/spf13/cobra" diff --git a/cmd/mv.go b/cmd/mv.go index 0a7fa52..cc1389d 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -18,7 +18,7 @@ import ( "fmt" "strings" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) diff --git a/cmd/output.go b/cmd/output.go index 1c28df4..e408237 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -6,7 +6,7 @@ import ( "reflect" "strings" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" dropboxauth "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" "github.com/spf13/cobra" @@ -25,6 +25,7 @@ const ( jsonErrorCodeEnvTokenStillActive = "env_token_still_active" jsonErrorCodeInvalidArguments = "invalid_arguments" jsonErrorCodeNotFound = "not_found" + jsonErrorCodePartialTransfer = "partial_transfer" jsonErrorCodePathConflict = "path_conflict" jsonErrorCodePermissionDenied = "permission_denied" jsonErrorCodeRateLimited = "rate_limited" @@ -33,6 +34,18 @@ const ( jsonErrorCodeUnknownFlag = "unknown_flag" ) +const ( + exitCodeSuccess = 0 + exitCodeGenericError = 1 + exitCodeAuthFailure = 2 + exitCodePermissionDenied = 3 + exitCodeNotFound = 4 + exitCodeConflict = 5 + exitCodeRateLimited = 6 + exitCodeValidationError = 7 + exitCodePartialTransfer = 8 +) + type jsonCodedError interface { error JSONErrorCode() string @@ -202,7 +215,11 @@ func parseOutputFormat(value string) (output.Format, error) { case output.FormatJSON: return output.FormatJSON, nil default: - return "", fmt.Errorf("unsupported output format %q: use text or json", value) + return "", invalidArgumentsErrorfWithDetails( + "unsupported output format %q: use text or json", + flagValueErrorDetails(outputFlag, value), + value, + ) } } @@ -276,6 +293,38 @@ func renderCommandErrorWithJSON(cmd *cobra.Command, err error, forceJSON bool) { } } +func exitCodeForError(err error) int { + if err == nil { + return exitCodeSuccess + } + + switch jsonErrorCode(err) { + case jsonErrorCodeAppKeyRequired, + jsonErrorCodeAuthExchangeFailed, + jsonErrorCodeAuthRefreshFailed, + jsonErrorCodeAuthRequired, + jsonErrorCodeEnvTokenStillActive: + return exitCodeAuthFailure + case jsonErrorCodePermissionDenied: + return exitCodePermissionDenied + case jsonErrorCodeNotFound: + return exitCodeNotFound + case jsonErrorCodePathConflict: + return exitCodeConflict + case jsonErrorCodeRateLimited: + return exitCodeRateLimited + case jsonErrorCodeInvalidArguments, + jsonErrorCodeStructuredOutputUnsupported, + jsonErrorCodeUnknownCommand, + jsonErrorCodeUnknownFlag: + return exitCodeValidationError + case jsonErrorCodePartialTransfer: + return exitCodePartialTransfer + default: + return exitCodeGenericError + } +} + // 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 { diff --git a/cmd/output_test.go b/cmd/output_test.go index 930578e..cd55342 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" dropboxauth "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" @@ -770,6 +770,11 @@ func TestJSONErrorCodeUsesCodedErrors(t *testing.T) { err: authRefreshFailedErrorf("refresh saved Dropbox credentials: failed"), want: jsonErrorCodeAuthRefreshFailed, }, + { + name: "partial transfer", + err: partialStdoutError(12), + want: jsonErrorCodePartialTransfer, + }, } for _, tt := range tests { @@ -781,6 +786,121 @@ func TestJSONErrorCodeUsesCodedErrors(t *testing.T) { } } +func TestExitCodeForStableJSONErrorCodes(t *testing.T) { + tests := []struct { + code string + want int + }{ + {jsonErrorCodeAppKeyRequired, exitCodeAuthFailure}, + {jsonErrorCodeAuthExchangeFailed, exitCodeAuthFailure}, + {jsonErrorCodeAuthRefreshFailed, exitCodeAuthFailure}, + {jsonErrorCodeAuthRequired, exitCodeAuthFailure}, + {jsonErrorCodeCommandFailed, exitCodeGenericError}, + {jsonErrorCodeDropboxAPIError, exitCodeGenericError}, + {jsonErrorCodeEnvTokenStillActive, exitCodeAuthFailure}, + {jsonErrorCodeInvalidArguments, exitCodeValidationError}, + {jsonErrorCodeNotFound, exitCodeNotFound}, + {jsonErrorCodePartialTransfer, exitCodePartialTransfer}, + {jsonErrorCodePathConflict, exitCodeConflict}, + {jsonErrorCodePermissionDenied, exitCodePermissionDenied}, + {jsonErrorCodeRateLimited, exitCodeRateLimited}, + {jsonErrorCodeStructuredOutputUnsupported, exitCodeValidationError}, + {jsonErrorCodeUnknownCommand, exitCodeValidationError}, + {jsonErrorCodeUnknownFlag, exitCodeValidationError}, + } + + if got := exitCodeForError(nil); got != exitCodeSuccess { + t.Fatalf("exitCodeForError(nil) = %d, want %d", got, exitCodeSuccess) + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + err := newCodedError(tt.code, errors.New(tt.code)) + if got := exitCodeForError(err); got != tt.want { + t.Fatalf("exitCodeForError(%s) = %d, want %d", tt.code, got, tt.want) + } + }) + } +} + +func TestExitCodeForCommonFailures(t *testing.T) { + tests := []struct { + name string + err error + want int + }{ + { + name: "generic", + err: errors.New("unexpected local failure"), + want: exitCodeGenericError, + }, + { + name: "auth", + err: authRequiredErrorf("no saved Dropbox credentials"), + want: exitCodeAuthFailure, + }, + { + name: "permission denied", + err: dropboxauth.AccessAPIError{}, + want: exitCodePermissionDenied, + }, + { + name: "not found", + err: files.GetMetadataAPIError{APIError: dropbox.APIError{ErrorSummary: "path/not_found/"}}, + want: exitCodeNotFound, + }, + { + name: "conflict", + err: pathConflictErrorWithPath("/file", "path exists and is not a folder: /file"), + want: exitCodeConflict, + }, + { + name: "rate limited", + err: dropboxauth.RateLimitAPIError{}, + want: exitCodeRateLimited, + }, + { + name: "validation", + err: invalidArgumentsErrorWithDetails("missing path", argumentErrorDetails("path")), + want: exitCodeValidationError, + }, + { + name: "partial transfer", + err: partialStdoutError(12), + want: exitCodePartialTransfer, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := exitCodeForError(tt.err); got != tt.want { + t.Fatalf("exitCodeForError(%v) = %d, want %d", tt.err, got, tt.want) + } + }) + } +} + +func TestParseOutputFormatInvalidValueIsValidationError(t *testing.T) { + _, err := parseOutputFormat("yaml") + if err == nil { + t.Fatal("expected invalid output format error") + } + if got := jsonErrorCode(err); got != jsonErrorCodeInvalidArguments { + t.Fatalf("jsonErrorCode = %q, want %q", got, jsonErrorCodeInvalidArguments) + } + if got := exitCodeForError(err); got != exitCodeValidationError { + t.Fatalf("exitCodeForError = %d, want %d", got, exitCodeValidationError) + } + + details := jsonErrorDetails(err) + if details["flag"] != outputFlag { + t.Fatalf("details[flag] = %v, want %q", details["flag"], outputFlag) + } + if details["value"] != "yaml" { + t.Fatalf("details[value] = %v, want yaml", details["value"]) + } +} + func TestJSONErrorCodeClassifiesDropboxAPIErrors(t *testing.T) { expiredAuth := dropboxauth.AuthAPIError{AuthError: &dropboxauth.AuthError{}} expiredAuth.AuthError.Tag = dropboxauth.AuthErrorExpiredAccessToken diff --git a/cmd/put.go b/cmd/put.go index e722b97..5bc42dc 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -27,7 +27,7 @@ import ( "sync" "time" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dustin/go-humanize" diff --git a/cmd/revs.go b/cmd/revs.go index 519f4e0..cecb6f1 100644 --- a/cmd/revs.go +++ b/cmd/revs.go @@ -19,7 +19,7 @@ import ( "io" "text/tabwriter" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) diff --git a/cmd/root.go b/cmd/root.go index e063a7c..be3a2c0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -248,7 +248,7 @@ func Execute() { cmd, err := RootCmd.ExecuteC() if err != nil { renderCommandErrorWithJSON(cmd, err, jsonErrorOutput) - os.Exit(1) + os.Exit(exitCodeForError(err)) } } diff --git a/cmd/root_test.go b/cmd/root_test.go index 1fd4f66..91a428a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -5,12 +5,13 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" "time" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/common" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/users" @@ -34,6 +35,129 @@ func TestRootCmdInvalidFlagReturnsError(t *testing.T) { } } +func TestExecuteExitsWithMappedCodes(t *testing.T) { + missingAuthFile := filepath.Join(t.TempDir(), "missing-auth.json") + + tests := []struct { + name string + args []string + env map[string]string + wantExitCode int + wantStdoutText string + wantStderrText string + }{ + { + name: "success", + args: []string{"--help"}, + wantExitCode: exitCodeSuccess, + wantStdoutText: "Usage:", + }, + { + name: "unknown command", + args: []string{"does-not-exist"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unknown command "does-not-exist"`, + }, + { + name: "json unknown command", + args: []string{"--output=json", "does-not-exist"}, + wantExitCode: exitCodeValidationError, + wantStdoutText: `"code":"unknown_command"`, + }, + { + name: "unsupported output format", + args: []string{"--output=yaml", "ls", "/"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unsupported output format "yaml"`, + }, + { + name: "last unsupported output format wins", + args: []string{"--output=json", "--output=yaml", "ls", "/"}, + wantExitCode: exitCodeValidationError, + wantStderrText: `unsupported output format "yaml"`, + }, + { + name: "auth required", + args: []string{"ls", "/"}, + env: map[string]string{envAccessToken: "", envAuthFile: missingAuthFile}, + wantExitCode: exitCodeAuthFailure, + wantStderrText: "no saved Dropbox credentials", + }, + { + name: "structured output unsupported", + args: []string{"completion", "--output=json"}, + wantExitCode: exitCodeValidationError, + wantStdoutText: `"code":"structured_output_unsupported"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exitCode, stdout, stderr := executeExitTestSubprocess(t, tt.args, tt.env) + if exitCode != tt.wantExitCode { + t.Fatalf("exit code = %d, want %d\nstdout: %s\nstderr: %s", exitCode, tt.wantExitCode, stdout, stderr) + } + if tt.wantStdoutText != "" && !strings.Contains(stdout, tt.wantStdoutText) { + t.Fatalf("stdout = %q, want %q", stdout, tt.wantStdoutText) + } + if tt.wantStderrText != "" && !strings.Contains(stderr, tt.wantStderrText) { + t.Fatalf("stderr = %q, want %q", stderr, tt.wantStderrText) + } + }) + } +} + +func executeExitTestSubprocess(t *testing.T, args []string, env map[string]string) (int, string, string) { + t.Helper() + + cmdArgs := append([]string{"-test.run=TestExecuteExitHelper", "--"}, args...) + cmd := exec.Command(os.Args[0], cmdArgs...) + cmd.Env = append(os.Environ(), "DBXCLI_TEST_EXECUTE_HELPER=1") + for key, value := range env { + cmd.Env = append(cmd.Env, key+"="+value) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err == nil { + return exitCodeSuccess, stdout.String(), stderr.String() + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), stdout.String(), stderr.String() + } + t.Fatalf("run helper subprocess: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + return exitCodeGenericError, stdout.String(), stderr.String() +} + +func TestExecuteExitHelper(t *testing.T) { + if os.Getenv("DBXCLI_TEST_EXECUTE_HELPER") != "1" { + return + } + + separator := -1 + for i, arg := range os.Args { + if arg == "--" { + separator = i + break + } + } + if separator < 0 { + os.Exit(exitCodeGenericError) + } + + args := append([]string(nil), os.Args[separator+1:]...) + os.Args = append([]string{"dbxcli"}, args...) + RootCmd.SetArgs(args) + Execute() + os.Exit(exitCodeSuccess) +} + func newAuthTestCommand() *cobra.Command { root := &cobra.Command{Use: "dbxcli"} root.PersistentFlags().String(outputFlag, "text", "") diff --git a/cmd/search.go b/cmd/search.go index 4b6e7d7..9d66f7b 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -20,7 +20,7 @@ import ( "strings" "text/tabwriter" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" diff --git a/cmd/share_link_download.go b/cmd/share_link_download.go index c2a3f68..b465c30 100644 --- a/cmd/share_link_download.go +++ b/cmd/share_link_download.go @@ -23,7 +23,7 @@ import ( "path/filepath" "strings" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/dustin/go-humanize" diff --git a/cmd/share_link_update.go b/cmd/share_link_update.go index 5d28836..5b29305 100644 --- a/cmd/share_link_update.go +++ b/cmd/share_link_update.go @@ -18,7 +18,7 @@ import ( "errors" "time" - "github.com/dropbox/dbxcli/internal/output" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/sharing" "github.com/spf13/cobra" ) diff --git a/cmd/stdout.go b/cmd/stdout.go index 56c9193..dbfa575 100644 --- a/cmd/stdout.go +++ b/cmd/stdout.go @@ -12,6 +12,22 @@ import ( var errStdoutBrokenPipe = errors.New("stdout broken pipe") +type partialTransferError struct { + bytesWritten int64 +} + +func (e partialTransferError) Error() string { + return fmt.Sprintf("download failed after writing %d bytes to stdout; cannot retry partial output", e.bytesWritten) +} + +func (e partialTransferError) JSONErrorCode() string { + return jsonErrorCodePartialTransfer +} + +func (e partialTransferError) JSONErrorDetails() map[string]any { + return map[string]any{"bytes_written": e.bytesWritten} +} + func downloadToStdout(dbx files.Client, src string, w io.Writer) error { ignoreBrokenPipeSignal() @@ -45,7 +61,7 @@ func downloadToStdout(dbx files.Client, src string, w io.Writer) error { } func partialStdoutError(bytesWritten int64) error { - return fmt.Errorf("download failed after writing %d bytes to stdout; cannot retry partial output", bytesWritten) + return partialTransferError{bytesWritten: bytesWritten} } type stdoutBrokenPipeWriter struct { diff --git a/docs/automation.md b/docs/automation.md index 96d8b1d..6f8e455 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -199,14 +199,24 @@ To upload a local file literally named `-`, use `./-`. ## Exit status -Current behavior: - -* `0` means success. -* Non-zero means failure. -* In JSON mode, inspect `error.code` for stable machine handling. - -Specific error-code-to-exit-code mapping is planned as a future automation -milestone. +`dbxcli` uses stable exit codes for shell scripts, CI jobs, and agents. Text +and JSON output modes use the same exit-code contract. Successful commands +exit `0`, including successful commands that return warnings. + +| Exit code | Meaning | JSON error codes | +|-----------|---------|------------------| +| `0` | Success | none | +| `1` | Generic error | `command_failed`, `dropbox_api_error` | +| `2` | Auth failure | `auth_required`, `auth_refresh_failed`, `auth_exchange_failed`, `app_key_required`, `env_token_still_active` | +| `3` | Permission denied | `permission_denied` | +| `4` | Not found | `not_found` | +| `5` | Conflict | `path_conflict` | +| `6` | Rate limited | `rate_limited` | +| `7` | Validation or usage error | `invalid_arguments`, `unknown_command`, `unknown_flag`, `structured_output_unsupported` | +| `8` | Partial stdout/output transfer | `partial_transfer` | + +In JSON mode, inspect both the process exit code and `error.code` for the most +specific machine-readable failure reason. ## Shell completion diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index c5709af..77e04d9 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -79,6 +79,7 @@ Stable error codes: | `app_key_required` | Login or token refresh needs a Dropbox app key. | | `auth_exchange_failed` | The OAuth authorization-code exchange failed or returned unusable tokens. | | `not_found` | Dropbox reported that the requested object was not found. | +| `partial_transfer` | A download-to-stdout stream failed after partial output was already written. | | `permission_denied` | Dropbox denied access because of permissions, scope, member selection, or state. | | `rate_limited` | Dropbox rate limited the request. | | `dropbox_api_error` | Dropbox returned an API error that does not map to a more specific code yet. | diff --git a/docs/json-schema/v1/error.schema.json b/docs/json-schema/v1/error.schema.json index 6b4c694..4edd34f 100644 --- a/docs/json-schema/v1/error.schema.json +++ b/docs/json-schema/v1/error.schema.json @@ -43,6 +43,7 @@ "app_key_required", "auth_exchange_failed", "not_found", + "partial_transfer", "permission_denied", "rate_limited", "dropbox_api_error", diff --git a/go.mod b/go.mod index 08394f2..2483ff1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/dropbox/dbxcli +module github.com/dropbox/dbxcli/v3 go 1.25.0 @@ -8,6 +8,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 golang.org/x/oauth2 v0.36.0 golang.org/x/term v0.44.0 ) @@ -16,7 +17,6 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.46.0 // indirect ) diff --git a/main.go b/main.go index cfb117f..434b711 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ package main import ( "log" - "github.com/dropbox/dbxcli/cmd" + "github.com/dropbox/dbxcli/v3/cmd" ) var version = "0.1.0" diff --git a/tools/gen-docs/main.go b/tools/gen-docs/main.go index aa0f9dd..199162d 100644 --- a/tools/gen-docs/main.go +++ b/tools/gen-docs/main.go @@ -22,7 +22,7 @@ import ( "sort" "strings" - "github.com/dropbox/dbxcli/cmd" + "github.com/dropbox/dbxcli/v3/cmd" "github.com/spf13/cobra" "github.com/spf13/cobra/doc" )