From 906c10a7135a1e745ad02127d78c889061bf22ea Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 25 Jun 2026 12:26:17 -0700 Subject: [PATCH] Harden JSON command contract Publish the command contract catalog, remove the unreachable unsupported_output_format JSON code, and reject unknown Dropbox metadata types instead of serializing unknown result kinds. Keep help/root JSON behavior documented and covered by tests, and tighten golden contract audits so public schemas and success fixtures stay in sync. --- README.md | 5 +- cmd/cp.go | 15 +- cmd/get.go | 13 +- cmd/json_contract_test.go | 67 +- cmd/json_metadata.go | 27 +- cmd/json_metadata_test.go | 21 +- cmd/json_output.go | 6 - cmd/ls.go | 25 +- cmd/mkdir.go | 14 +- cmd/mv.go | 15 +- cmd/output.go | 7 +- cmd/output_test.go | 11 +- cmd/put.go | 30 +- cmd/relocation_output.go | 10 +- cmd/restore.go | 26 +- cmd/restore_test.go | 5 +- cmd/revs.go | 16 +- cmd/rm.go | 25 +- cmd/rm_test.go | 10 +- cmd/root_test.go | 54 ++ cmd/search.go | 6 +- .../json_contract/success_schemas.json | 18 +- docs/json-schema/v1/README.md | 7 +- docs/json-schema/v1/commands.json | 699 ++++++++++++++++++ docs/json-schema/v1/error.schema.json | 1 - 25 files changed, 1015 insertions(+), 118 deletions(-) create mode 100644 docs/json-schema/v1/commands.json diff --git a/README.md b/README.md index 816d424..e17e254 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Structured success output is rolling out command by command. Currently migrated Command results and JSON errors are written to stdout. Status, progress, human-facing warnings, diagnostics, and verbose logs are written to stderr. JSON errors include a `warnings` array for machine-actionable warnings; it is `[]` when no warnings are present. Successful JSON payloads use the same `warnings` field. Current warning codes include `deprecated_command` for deprecated command paths and `skipped_symlink` for symlinks skipped by recursive upload. -Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Cobra help output and shell-completion protocol commands are also text-only. +Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Cobra help output and shell-completion protocol commands are also text-only: `dbxcli --help --output=json`, `dbxcli --output=json` without a command, and command-specific help such as `dbxcli version --help --output=json` print text help. JSON error responses use stable `error.code` values: @@ -187,12 +187,11 @@ JSON error responses use stable `error.code` values: | `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. | | `structured_output_unsupported` | The command does not support `--output=json` yet. | -| `unsupported_output_format` | `--output` was not `text` or `json`. | | `unknown_command` | Cobra could not resolve the command. | | `unknown_flag` | Cobra could not resolve a flag. | | `command_failed` | Fallback for failures without a more specific stable code. | -Successful JSON responses for migrated commands return `ok: true`, `schema_version: "1"`, `command`, an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. Public top-level schemas live under [docs/json-schema/v1](docs/json-schema/v1/). If a multi-target or recursive command fails after some side effects have already happened, dbxcli returns a JSON error envelope and does not include partial success results. For commands such as `mkdir`, each result reports what happened to the requested path: +Successful JSON responses for migrated commands return `ok: true`, `schema_version: "1"`, `command`, an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. Public top-level schemas and the command contract catalog live under [docs/json-schema/v1](docs/json-schema/v1/). If a multi-target or recursive command fails after some side effects have already happened, dbxcli returns a JSON error envelope and does not include partial success results. For commands such as `mkdir`, each result reports what happened to the requested path: ```json { diff --git a/cmd/cp.go b/cmd/cp.go index b8ef34e..c2c4441 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -18,6 +18,7 @@ import ( "fmt" "strings" + "github.com/dropbox/dbxcli/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) @@ -39,6 +40,7 @@ func cp(cmd *cobra.Command, args []string) error { var cpErrors []error var relocationArgs []*files.RelocationArg var results []relocationResult + collectResults := commandOutputFormat(cmd) == output.FormatJSON dbx := filesNewFunc(config) destIsFolder := len(argsToCopy) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination) @@ -61,7 +63,15 @@ func cp(cmd *cobra.Command, args []string) error { cpErrors = append(cpErrors, copyError) continue } - results = append(results, newRelocationResult(arg, res)) + if collectResults { + result, err := newRelocationResult(arg, res) + if err != nil { + copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err) + cpErrors = append(cpErrors, copyError) + continue + } + results = append(results, result) + } } if len(cpErrors) > 0 { @@ -71,6 +81,9 @@ func cp(cmd *cobra.Command, args []string) error { return fmt.Errorf("cp: %d error(s)", len(cpErrors)) } + if !collectResults { + return nil + } return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusCopied, results)) } diff --git a/cmd/get.go b/cmd/get.go index 11cd69e..cc85286 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -162,7 +162,7 @@ func getErrorOutput(opts getOptions) io.Writer { return os.Stderr } -func newGetResult(status, kind, source, target string, metadata files.IsMetadata) getResult { +func newGetResult(status, kind, source, target string, metadata files.IsMetadata) (getResult, error) { result := getResult{ Status: status, Kind: kind, @@ -172,10 +172,13 @@ func newGetResult(status, kind, source, target string, metadata files.IsMetadata }, } if metadata != nil { - jsonResult := jsonMetadataFromDropbox(metadata) + jsonResult, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return getResult{}, err + } result.Result = &jsonResult } - return result + return result, nil } func renderGetResults(cmd *cobra.Command, input getCommandInput, results []getResult) error { @@ -352,7 +355,7 @@ func ensureLocalDirectoryResult(source, target string, metadata files.IsMetadata if err := os.MkdirAll(target, 0755); err != nil { return getResult{}, err } - return newGetResult(status, getKindFolder, source, target, metadata), nil + return newGetResult(status, getKindFolder, source, target, metadata) } func relativeTo(base, full string) (string, error) { @@ -376,7 +379,7 @@ func downloadFileWithResult(dbx files.Client, src string, dst string, opts getOp if err != nil { return getResult{}, err } - return newGetResult(getStatusDownloaded, getKindFile, src, dst, metadata), nil + return newGetResult(getStatusDownloaded, getKindFile, src, dst, metadata) } func downloadFileWithMetadata(dbx files.Client, src string, dst string, errOut io.Writer) (*files.FileMetadata, error) { diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 07f1940..dff641f 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -130,11 +130,13 @@ func TestStructuredOutputGoldenSuccessOutputAudit(t *testing.T) { continue } assertGoldenJSONEqual(t, command, fixture, example) + assertGoldenSuccessOutputStatuses(t, command, fixture) } for command := range fixtures { if !structuredSet[command] { t.Errorf("golden success output includes non-structured command %q", command) } + assertGoldenSuccessOutputStatuses(t, command, fixtures[command]) } for command := range examples { if !structuredSet[command] { @@ -195,6 +197,18 @@ func TestPublicJSONSchemaFiles(t *testing.T) { } } +func TestPublicJSONCommandCatalogMatchesGoldenContract(t *testing.T) { + got := loadJSONContractFile(t, "../docs/json-schema/v1/commands.json", "public command catalog") + want := loadJSONGoldenContract(t) + if reflect.DeepEqual(got, want) { + return + } + + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("public command catalog = %s, want %s", gotJSON, wantJSON) +} + func structuredOutputCommandPathsWithVersion() []string { paths := structuredOutputCommandPaths(RootCmd) return append(paths, NewVersionCommand("test").Name()) @@ -379,9 +393,15 @@ func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture { func loadJSONGoldenContract(t *testing.T) jsonGoldenContract { t.Helper() - data, err := os.ReadFile("testdata/json_contract/success_schemas.json") + return loadJSONContractFile(t, "testdata/json_contract/success_schemas.json", "golden schema fixture") +} + +func loadJSONContractFile(t *testing.T, file string, label string) jsonGoldenContract { + t.Helper() + + data, err := os.ReadFile(file) if err != nil { - t.Fatalf("read golden schema fixture: %v", err) + t.Fatalf("read %s %s: %v", label, file, err) } var raw struct { @@ -389,13 +409,13 @@ func loadJSONGoldenContract(t *testing.T) jsonGoldenContract { Commands map[string]map[string]json.RawMessage `json:"commands"` } if err := json.Unmarshal(data, &raw); err != nil { - t.Fatalf("decode raw golden schema fixture: %v", err) + t.Fatalf("decode raw %s %s: %v", label, file, err) } if len(raw.Definitions) == 0 { - t.Fatalf("golden schema fixture has no definitions") + t.Fatalf("%s %s has no definitions", label, file) } if len(raw.Commands) == 0 { - t.Fatalf("golden schema fixture has no commands") + t.Fatalf("%s %s has no commands", label, file) } requiredCommandFields := []string{ @@ -411,14 +431,14 @@ func loadJSONGoldenContract(t *testing.T) jsonGoldenContract { for command, fields := range raw.Commands { for _, field := range requiredCommandFields { if _, ok := fields[field]; !ok { - t.Errorf("golden schema for %q missing %q", command, field) + t.Errorf("%s for %q missing %q", label, command, field) } } } var contract jsonGoldenContract if err := json.Unmarshal(data, &contract); err != nil { - t.Fatalf("decode golden schema fixture: %v", err) + t.Fatalf("decode %s %s: %v", label, file, err) } return normalizeGoldenContract(contract) } @@ -472,7 +492,6 @@ func expectedJSONErrorCodes() []string { jsonErrorCodeStructuredOutputUnsupported, jsonErrorCodeUnknownCommand, jsonErrorCodeUnknownFlag, - jsonErrorCodeUnsupportedOutputFormat, } } @@ -501,6 +520,29 @@ func assertGoldenJSONEqual(t *testing.T, command string, fixture json.RawMessage t.Errorf("golden output for %q = %s, want %s", command, gotJSON, wantJSON) } +func assertGoldenSuccessOutputStatuses(t *testing.T, command string, fixture json.RawMessage) { + t.Helper() + + var output jsonOperationOutput + if err := json.Unmarshal(fixture, &output); err != nil { + t.Fatalf("decode golden output for %q: %v", command, err) + } + for i, result := range output.Results { + if result.Status == "" { + t.Errorf("golden output for %q result %d has empty status", command, i) + } + if result.Status == "unknown" { + t.Errorf("golden output for %q result %d must not use unknown status", command, i) + } + if result.Kind == "" { + t.Errorf("golden output for %q result %d has empty kind", command, i) + } + if result.Kind == "unknown" { + t.Errorf("golden output for %q result %d must not use unknown kind", command, i) + } + } +} + func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { file := sampleJSONFileMetadata("/Reports/old.pdf") copyFile := sampleJSONFileMetadata("/Reports/copy.pdf") @@ -789,7 +831,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved}, metadataKinds(), nil), "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}), "restore": operationSchema("restore_input", schemaRef("restore_input"), "metadata", []string{restoreStatusRestored}, []string{restoreKindFile}, nil), - "revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file", "unknown"}, nil), + "revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file"}, nil), "rm": operationSchema("empty", schemaRef("remove_input"), "metadata", []string{removeJSONStatusDeleted, removeJSONStatusPermanentlyDeleted}, metadataKinds(), nil), "search": operationSchema("search_input", schemaRef("empty"), "metadata", []string{searchJSONStatusFound}, metadataKinds(), nil), "share list folder": operationSchema("empty", schemaRef("empty"), "share_folder", []string{shareFolderJSONStatusListed}, []string{shareFolderJSONKindFolder}, nil), @@ -834,7 +876,7 @@ func schemaRef(name string) *string { } func metadataKinds() []string { - return []string{"deleted", "file", "folder", "unknown"} + return []string{"deleted", "file", "folder"} } func shareLinkKinds() []string { @@ -933,6 +975,11 @@ func assertGoldenCommandStatuses(t *testing.T, command string, schema jsonGolden t.Errorf("golden schema for %q must not allow unknown result status", command) } } + for _, kind := range schema.Kinds { + if kind == "unknown" { + t.Errorf("golden schema for %q must not allow unknown result kind", command) + } + } } func normalizeGoldenContract(contract jsonGoldenContract) jsonGoldenContract { diff --git a/cmd/json_metadata.go b/cmd/json_metadata.go index 5af1a27..d8d7a03 100644 --- a/cmd/json_metadata.go +++ b/cmd/json_metadata.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "time" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" @@ -18,11 +19,11 @@ type jsonMetadata struct { Deleted bool `json:"deleted,omitempty"` } -func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata { +func jsonMetadataFromDropbox(metadata files.IsMetadata) (jsonMetadata, error) { switch m := metadata.(type) { case *files.FileMetadata: if m == nil { - return jsonMetadata{Type: "unknown"} + return jsonMetadata{}, fmt.Errorf("unexpected nil Dropbox file metadata") } size := m.Size return jsonMetadata{ @@ -34,38 +35,42 @@ func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata { Size: &size, ServerModified: jsonTime(m.ServerModified), ClientModified: jsonTime(m.ClientModified), - } + }, nil case *files.FolderMetadata: if m == nil { - return jsonMetadata{Type: "unknown"} + return jsonMetadata{}, fmt.Errorf("unexpected nil Dropbox folder metadata") } return jsonMetadata{ Type: "folder", PathDisplay: m.PathDisplay, PathLower: m.PathLower, ID: m.Id, - } + }, nil case *files.DeletedMetadata: if m == nil { - return jsonMetadata{Type: "unknown"} + return jsonMetadata{}, fmt.Errorf("unexpected nil Dropbox deleted metadata") } return jsonMetadata{ Type: "deleted", PathDisplay: m.PathDisplay, PathLower: m.PathLower, Deleted: true, - } + }, nil default: - return jsonMetadata{Type: "unknown"} + return jsonMetadata{}, fmt.Errorf("unexpected Dropbox metadata type %T", metadata) } } -func jsonMetadataListFromDropbox(entries []files.IsMetadata) []jsonMetadata { +func jsonMetadataListFromDropbox(entries []files.IsMetadata) ([]jsonMetadata, error) { result := make([]jsonMetadata, 0, len(entries)) for _, entry := range entries { - result = append(result, jsonMetadataFromDropbox(entry)) + metadata, err := jsonMetadataFromDropbox(entry) + if err != nil { + return nil, err + } + result = append(result, metadata) } - return result + return result, nil } func jsonTime(t time.Time) *string { diff --git a/cmd/json_metadata_test.go b/cmd/json_metadata_test.go index 6651642..2b1144c 100644 --- a/cmd/json_metadata_test.go +++ b/cmd/json_metadata_test.go @@ -24,7 +24,10 @@ func TestJSONMetadataFromDropboxFile(t *testing.T) { ServerModified: serverModified, } - got := jsonMetadataFromDropbox(metadata) + got, err := jsonMetadataFromDropbox(metadata) + if err != nil { + t.Fatal(err) + } if got.Type != "file" { t.Fatalf("Type = %q, want file", got.Type) @@ -63,7 +66,10 @@ func TestJSONMetadataFromDropboxFolder(t *testing.T) { Id: "id:folder", } - got := jsonMetadataFromDropbox(metadata) + got, err := jsonMetadataFromDropbox(metadata) + if err != nil { + t.Fatal(err) + } if got.Type != "folder" { t.Fatalf("Type = %q, want folder", got.Type) @@ -84,7 +90,10 @@ func TestJSONMetadataFromDropboxDeleted(t *testing.T) { }, } - got := jsonMetadataFromDropbox(metadata) + got, err := jsonMetadataFromDropbox(metadata) + if err != nil { + t.Fatal(err) + } if got.Type != "deleted" { t.Fatalf("Type = %q, want deleted", got.Type) @@ -93,3 +102,9 @@ func TestJSONMetadataFromDropboxDeleted(t *testing.T) { t.Fatal("Deleted = false, want true") } } + +func TestJSONMetadataFromDropboxRejectsUnknownMetadata(t *testing.T) { + if _, err := jsonMetadataFromDropbox(nil); err == nil { + t.Fatal("expected nil metadata to fail") + } +} diff --git a/cmd/json_output.go b/cmd/json_output.go index 147a1ad..62d55fc 100644 --- a/cmd/json_output.go +++ b/cmd/json_output.go @@ -122,12 +122,6 @@ func normalizeJSONOperationResults(results []jsonOperationResult) []jsonOperatio } func normalizeJSONOperationResult(result jsonOperationResult) jsonOperationResult { - if result.Status == "" { - result.Status = "unknown" - } - if result.Kind == "" { - result.Kind = "unknown" - } result.Input = normalizeJSONObject(result.Input) result.Result = normalizeJSONObject(result.Result) return result diff --git a/cmd/ls.go b/cmd/ls.go index 5bdaa5a..c512729 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -233,7 +233,11 @@ func renderLsOutput(cmd *cobra.Command, path string, arg *files.ListFolderArg, o } input := newLsInput(path, arg, onlyDeleted, opts) - results := newJSONMetadataOperationResults(lsJSONStatusListed, jsonMetadataListFromLsEntries(entries)) + metadata, err := jsonMetadataListFromLsEntries(entries) + if err != nil { + return err + } + results := newJSONMetadataOperationResults(lsJSONStatusListed, metadata) return renderJSONOperationOutput(cmd, input, results) } @@ -255,21 +259,28 @@ func newLsInput(path string, arg *files.ListFolderArg, onlyDeleted bool, opts li } } -func jsonMetadataListFromLsEntries(entries []files.IsMetadata) []jsonMetadata { +func jsonMetadataListFromLsEntries(entries []files.IsMetadata) ([]jsonMetadata, error) { result := make([]jsonMetadata, 0, len(entries)) for _, entry := range entries { - result = append(result, jsonMetadataFromLsEntry(entry)) + metadata, err := jsonMetadataFromLsEntry(entry) + if err != nil { + return nil, err + } + result = append(result, metadata) } - return result + return result, nil } -func jsonMetadataFromLsEntry(entry files.IsMetadata) jsonMetadata { - result := jsonMetadataFromDropbox(entry) +func jsonMetadataFromLsEntry(entry files.IsMetadata) (jsonMetadata, error) { + result, err := jsonMetadataFromDropbox(entry) + if err != nil { + return jsonMetadata{}, err + } if path, ok := undecoratedDeletedPath(result.PathDisplay); ok { result.PathDisplay = path result.Deleted = true } - return result + return result, nil } func undecoratedDeletedPath(path string) (string, bool) { diff --git a/cmd/mkdir.go b/cmd/mkdir.go index ca0d0bd..7aa3154 100644 --- a/cmd/mkdir.go +++ b/cmd/mkdir.go @@ -98,7 +98,10 @@ func mkdir(cmd *cobra.Command, args []string) (err error) { metadata = created.Metadata } - result := newMkdirResult(status, dst, parents, metadata) + result, err := newMkdirResult(status, dst, parents, metadata) + if err != nil { + return err + } return renderJSONOperationOutput(cmd, result.Input, []jsonOperationResult{mkdirOperationResult(result)}) } @@ -114,8 +117,11 @@ func existingFolderMetadata(dbx files.Client, dst string) (*files.FolderMetadata return folder, nil } -func newMkdirResult(status, path string, parents bool, metadata *files.FolderMetadata) mkdirResult { - result := jsonMetadataFromDropbox(metadata) +func newMkdirResult(status, path string, parents bool, metadata *files.FolderMetadata) (mkdirResult, error) { + result, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return mkdirResult{}, err + } result.PathDisplay = metadataDisplayPath(path, result.PathDisplay) return mkdirResult{ @@ -126,7 +132,7 @@ func newMkdirResult(status, path string, parents bool, metadata *files.FolderMet Parents: parents, }, Result: result, - } + }, nil } func mkdirOperationResult(result mkdirResult) jsonOperationResult { diff --git a/cmd/mv.go b/cmd/mv.go index f3a481c..6ae65a3 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -18,6 +18,7 @@ import ( "fmt" "strings" + "github.com/dropbox/dbxcli/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) @@ -39,6 +40,7 @@ func mv(cmd *cobra.Command, args []string) error { var mvErrors []error var relocationArgs []*files.RelocationArg var results []relocationResult + collectResults := commandOutputFormat(cmd) == output.FormatJSON dbx := filesNewFunc(config) destIsFolder := len(argsToMove) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination) @@ -60,7 +62,15 @@ func mv(cmd *cobra.Command, args []string) error { mvErrors = append(mvErrors, moveError) continue } - results = append(results, newRelocationResult(arg, res)) + if collectResults { + result, err := newRelocationResult(arg, res) + if err != nil { + moveError := fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err) + mvErrors = append(mvErrors, moveError) + continue + } + results = append(results, result) + } } if len(mvErrors) > 0 { @@ -70,6 +80,9 @@ func mv(cmd *cobra.Command, args []string) error { return fmt.Errorf("mv: %d error(s)", len(mvErrors)) } + if !collectResults { + return nil + } return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusMoved, results)) } diff --git a/cmd/output.go b/cmd/output.go index 2fd230b..df64871 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -30,7 +30,6 @@ const ( jsonErrorCodeStructuredOutputUnsupported = "structured_output_unsupported" jsonErrorCodeUnknownCommand = "unknown_command" jsonErrorCodeUnknownFlag = "unknown_flag" - jsonErrorCodeUnsupportedOutputFormat = "unsupported_output_format" ) type jsonCodedError interface { @@ -101,10 +100,6 @@ func authRefreshFailedErrorf(format string, args ...any) error { return newCodedError(jsonErrorCodeAuthRefreshFailed, fmt.Errorf(format, args...)) } -func unsupportedOutputFormatErrorf(format string, args ...any) error { - return newCodedError(jsonErrorCodeUnsupportedOutputFormat, fmt.Errorf(format, args...)) -} - func commandOutput(cmd *cobra.Command) *output.Renderer { if cmd == nil { return output.New(nil, nil, output.FormatText) @@ -152,7 +147,7 @@ func parseOutputFormat(value string) (output.Format, error) { case output.FormatJSON: return output.FormatJSON, nil default: - return "", unsupportedOutputFormatErrorf("unsupported output format %q: use text or json", value) + return "", fmt.Errorf("unsupported output format %q: use text or json", value) } } diff --git a/cmd/output_test.go b/cmd/output_test.go index 86b6e9c..fa2a831 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -455,7 +455,7 @@ func TestRenderJSONOperationOutput(t *testing.T) { } } -func TestNewJSONOperationResultNormalizesEmptyFields(t *testing.T) { +func TestNewJSONOperationResultNormalizesObjectFields(t *testing.T) { got := newJSONOperationResult( "", "", @@ -471,8 +471,8 @@ func TestNewJSONOperationResultNormalizesEmptyFields(t *testing.T) { } rendered := string(encoded) for _, want := range []string{ - `"status":"unknown"`, - `"kind":"unknown"`, + `"status":""`, + `"kind":""`, `"input":{"path":"/file.txt"}`, `"result":{}`, } { @@ -634,11 +634,6 @@ func TestJSONErrorCodeUsesCodedErrors(t *testing.T) { err: invalidArgumentsError("`add-member` requires `email`, `first`, and `last` arguments"), want: jsonErrorCodeInvalidArguments, }, - { - name: "unsupported output format", - err: unsupportedOutputFormatErrorf("unsupported output format %q: use text or json", "yaml"), - want: jsonErrorCodeUnsupportedOutputFormat, - }, { name: "auth required", err: authRequiredErrorf("no saved Dropbox credentials"), diff --git a/cmd/put.go b/cmd/put.go index 3a9551f..87e998c 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -243,7 +243,7 @@ const ( putDestinationSkip ) -func newPutResult(status, kind, source, target string, metadata files.IsMetadata) putResult { +func newPutResult(status, kind, source, target string, metadata files.IsMetadata) (putResult, error) { result := putResult{ Status: status, Kind: kind, @@ -253,10 +253,13 @@ func newPutResult(status, kind, source, target string, metadata files.IsMetadata }, } if metadata != nil { - jsonResult := jsonMetadataFromDropbox(metadata) + jsonResult, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return putResult{}, err + } result.Result = &jsonResult } - return result + return result, nil } func renderPutResults(cmd *cobra.Command, input putCommandInput, results []putResult) error { @@ -376,7 +379,10 @@ func putStdin(cmd *cobra.Command, args []string, opts putOptions, recursive bool } if action == putDestinationSkip { reportPutSkipped(opts, dstPath) - result := newPutResult(putStatusSkipped, putKindFile, "-", dstPath, existingMetadata) + result, err := newPutResult(putStatusSkipped, putKindFile, "-", dstPath, existingMetadata) + if err != nil { + return err + } return renderPutResults(cmd, putCommandInput{ Source: "-", Target: dstPath, @@ -502,7 +508,7 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { } if action == putDestinationSkip { reportPutSkipped(opts, dst) - return newPutResult(putStatusSkipped, putKindFile, src, dst, existingMetadata), nil + return newPutResult(putStatusSkipped, putKindFile, src, dst, existingMetadata) } contents, err := os.Open(src) @@ -528,24 +534,24 @@ func putFileWithResult(src, dst string, opts putOptions) (putResult, error) { metadata, err := uploadChunked(dbx, uploadProgressReader(contents, contentsInfo.Size(), putErrorOutput(opts)), commitInfo, contentsInfo.Size(), opts.workers, opts.chunkSize, opts.debug) if err != nil && ifExists == putIfExistsSkip && isUploadDestinationFileConflict(err) { reportPutSkipped(opts, dst) - return newPutResult(putStatusSkipped, putKindFile, src, dst, nil), nil + return newPutResult(putStatusSkipped, putKindFile, src, dst, nil) } if err != nil { return putResult{}, err } - return newPutResult(putStatusUploaded, putKindFile, src, dst, metadata), nil + return newPutResult(putStatusUploaded, putKindFile, src, dst, metadata) } uploadArg := &files.UploadArg{CommitInfo: *commitInfo} metadata, err := uploadSingleShot(dbx, contents, uploadArg, contentsInfo.Size(), putErrorOutput(opts)) if err != nil && ifExists == putIfExistsSkip && isUploadDestinationFileConflict(err) { reportPutSkipped(opts, dst) - return newPutResult(putStatusSkipped, putKindFile, src, dst, nil), nil + return newPutResult(putStatusSkipped, putKindFile, src, dst, nil) } if err != nil { return putResult{}, err } - return newPutResult(putStatusUploaded, putKindFile, src, dst, metadata), nil + return newPutResult(putStatusUploaded, putKindFile, src, dst, metadata) } func writeModeForIfExists(ifExists string) string { @@ -849,12 +855,12 @@ func putDirectoryWithResult(dbx files.Client, src, dst string) (putResult, error if metaErr != nil { return putResult{}, metaErr } - return newPutResult(putStatusExisting, putKindFolder, src, dst, metadata), nil + return newPutResult(putStatusExisting, putKindFolder, src, dst, metadata) } if created == nil { - return newPutResult(putStatusCreated, putKindFolder, src, dst, nil), nil + return newPutResult(putStatusCreated, putKindFolder, src, dst, nil) } - return newPutResult(putStatusCreated, putKindFolder, src, dst, created.Metadata), nil + return newPutResult(putStatusCreated, putKindFolder, src, dst, created.Metadata) } func putDirectoryConflictError(dst string, err error) error { diff --git a/cmd/relocation_output.go b/cmd/relocation_output.go index 86b2dcb..9bee1e8 100644 --- a/cmd/relocation_output.go +++ b/cmd/relocation_output.go @@ -17,19 +17,23 @@ type relocationResult struct { Result jsonMetadata `json:"result"` } -func newRelocationResult(arg *files.RelocationArg, res *files.RelocationResult) relocationResult { +func newRelocationResult(arg *files.RelocationArg, res *files.RelocationResult) (relocationResult, error) { var metadata files.IsMetadata if res != nil { metadata = res.Metadata } + result, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return relocationResult{}, err + } return relocationResult{ Input: relocationInput{ FromPath: arg.FromPath, ToPath: arg.ToPath, }, - Result: jsonMetadataFromDropbox(metadata), - } + Result: result, + }, nil } func relocationOperationResults(status string, results []relocationResult) []jsonOperationResult { diff --git a/cmd/restore.go b/cmd/restore.go index 3d3146a..c34ecbd 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -61,7 +61,10 @@ func restore(cmd *cobra.Command, args []string) (err error) { } verbose, _ := cmd.Flags().GetBool("verbose") - result := newRestoreResult(path, rev, metadata) + result, err := newRestoreResult(path, rev, metadata) + if err != nil { + return err + } return commandOutput(cmd).Render(func(w io.Writer) error { if !verbose { @@ -71,7 +74,11 @@ func restore(cmd *cobra.Command, args []string) (err error) { }, newJSONCommandOperationOutput(cmd, result.Input, []jsonOperationResult{restoreOperationResult(result)}, nil)) } -func newRestoreResult(path, revision string, metadata *files.FileMetadata) restoreResult { +func newRestoreResult(path, revision string, metadata *files.FileMetadata) (restoreResult, error) { + result, err := restoreMetadataFromDropbox(path, metadata) + if err != nil { + return restoreResult{}, err + } return restoreResult{ Status: restoreStatusRestored, Kind: restoreKindFile, @@ -79,25 +86,28 @@ func newRestoreResult(path, revision string, metadata *files.FileMetadata) resto Path: path, Revision: revision, }, - Result: restoreMetadataFromDropbox(path, metadata), - } + Result: result, + }, nil } func restoreOperationResult(result restoreResult) jsonOperationResult { return newJSONOperationResult(result.Status, result.Kind, result.Input, result.Result) } -func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) jsonMetadata { +func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) (jsonMetadata, error) { if metadata == nil { return jsonMetadata{ Type: "file", PathDisplay: path, - } + }, nil } - result := jsonMetadataFromDropbox(metadata) + result, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return jsonMetadata{}, err + } result.PathDisplay = metadataDisplayPath(path, result.PathDisplay) - return result + return result, nil } func renderRestoreResult(w io.Writer, result restoreResult) error { diff --git a/cmd/restore_test.go b/cmd/restore_test.go index 086ef68..0b0222d 100644 --- a/cmd/restore_test.go +++ b/cmd/restore_test.go @@ -100,7 +100,7 @@ func TestRestoreVerbosePrintsRevisionAndServerModifiedTime(t *testing.T) { func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) { clientModified := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC) - result := newRestoreResult("/Reports/old.pdf", "target-rev", &files.FileMetadata{ + result, err := newRestoreResult("/Reports/old.pdf", "target-rev", &files.FileMetadata{ Metadata: files.Metadata{ PathDisplay: "/Reports/old.pdf", PathLower: "/reports/old.pdf", @@ -111,6 +111,9 @@ func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) { ClientModified: clientModified, ServerModified: serverModified, }) + if err != nil { + t.Fatal(err) + } if result.Input.Path != "/Reports/old.pdf" || result.Input.Revision != "target-rev" { t.Fatalf("input = %#v, want path and target revision", result.Input) diff --git a/cmd/revs.go b/cmd/revs.go index 6035781..4583b61 100644 --- a/cmd/revs.go +++ b/cmd/revs.go @@ -65,7 +65,11 @@ func renderRevisionsOutput(cmd *cobra.Command, path string, entries []*files.Fil } input := newRevsInput(path, opts) - results := newJSONMetadataOperationResults(revsJSONStatusRevision, jsonMetadataListFromRevisions(entries)) + metadata, err := jsonMetadataListFromRevisions(entries) + if err != nil { + return err + } + results := newJSONMetadataOperationResults(revsJSONStatusRevision, metadata) return renderJSONOperationOutput(cmd, input, results) } @@ -78,12 +82,16 @@ func newRevsInput(path string, opts listOptions) revsInput { } } -func jsonMetadataListFromRevisions(entries []*files.FileMetadata) []jsonMetadata { +func jsonMetadataListFromRevisions(entries []*files.FileMetadata) ([]jsonMetadata, error) { result := make([]jsonMetadata, 0, len(entries)) for _, entry := range entries { - result = append(result, jsonMetadataFromDropbox(entry)) + metadata, err := jsonMetadataFromDropbox(entry) + if err != nil { + return nil, err + } + result = append(result, metadata) } - return result + return result, nil } func renderRevisionResults(out io.Writer, entries []*files.FileMetadata, opts listOptions) error { diff --git a/cmd/rm.go b/cmd/rm.go index f666991..6850110 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -175,13 +175,21 @@ func removeTargets(dbx files.Client, targets []removeTarget, opts removeOptions) } } - results = append(results, newRemoveResult(target.path, metadata, opts)) + result, err := newRemoveResult(target.path, metadata, opts) + if err != nil { + return nil, err + } + results = append(results, result) } return results, nil } -func newRemoveResult(path string, metadata files.IsMetadata, opts removeOptions) removeResult { +func newRemoveResult(path string, metadata files.IsMetadata, opts removeOptions) (removeResult, error) { + result, err := removeMetadataFromDropbox(path, metadata) + if err != nil { + return removeResult{}, err + } return removeResult{ Input: removeInput{ Path: path, @@ -189,14 +197,17 @@ func newRemoveResult(path string, metadata files.IsMetadata, opts removeOptions) Recursive: opts.recursive, Force: opts.force, }, - Result: removeMetadataFromDropbox(path, metadata), - } + Result: result, + }, nil } -func removeMetadataFromDropbox(path string, metadata files.IsMetadata) jsonMetadata { - result := jsonMetadataFromDropbox(metadata) +func removeMetadataFromDropbox(path string, metadata files.IsMetadata) (jsonMetadata, error) { + result, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return jsonMetadata{}, err + } result.PathDisplay = metadataDisplayPath(path, result.PathDisplay) - return result + return result, nil } func renderRemoveResults(w io.Writer, results []removeResult) error { diff --git a/cmd/rm_test.go b/cmd/rm_test.go index 31f4499..0b04f1c 100644 --- a/cmd/rm_test.go +++ b/cmd/rm_test.go @@ -233,7 +233,10 @@ func TestRmForceStillDeletesNonEmptyFolder(t *testing.T) { } func TestRemoveResultInputKeepsForceAndRecursiveSeparate(t *testing.T) { - result := newRemoveResult("/folder", rmFolderMetadata("/folder"), removeOptions{force: true}) + result, err := newRemoveResult("/folder", rmFolderMetadata("/folder"), removeOptions{force: true}) + if err != nil { + t.Fatal(err) + } if !result.Input.Force { t.Fatal("Force = false, want true") } @@ -241,7 +244,10 @@ func TestRemoveResultInputKeepsForceAndRecursiveSeparate(t *testing.T) { t.Fatal("Recursive = true for force-only delete, want false") } - result = newRemoveResult("/folder", rmFolderMetadata("/folder"), removeOptions{recursive: true}) + result, err = newRemoveResult("/folder", rmFolderMetadata("/folder"), removeOptions{recursive: true}) + if err != nil { + t.Fatal(err) + } if result.Input.Force { t.Fatal("Force = true for recursive-only delete, want false") } diff --git a/cmd/root_test.go b/cmd/root_test.go index 807d69e..98ea33d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -160,6 +160,60 @@ func TestCompletionJSONUnsupportedOutputReturnsError(t *testing.T) { } } +func TestHelpOutputRemainsTextWithJSONFlag(t *testing.T) { + tests := []struct { + name string + args []string + }{ + { + name: "root help", + args: []string{"--help", "--output=json"}, + }, + { + name: "root no command", + args: []string{"--output=json"}, + }, + { + name: "command help", + args: []string{"version", "--help", "--output=json"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + root := &cobra.Command{ + Use: "dbxcli", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: initDbx, + } + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(tt.args) + root.PersistentFlags().BoolP("verbose", "v", false, "") + root.PersistentFlags().String(outputFlag, "text", "") + root.PersistentFlags().String("as-member", "", "") + root.PersistentFlags().String("domain", "", "") + root.AddCommand(NewVersionCommand("test-version")) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if got := stdout.String(); !strings.Contains(got, "Usage:") { + t.Fatalf("stdout = %q, want text help", got) + } + if strings.Contains(stdout.String(), `"ok"`) { + t.Fatalf("stdout = %q, want text help without JSON envelope", stdout.String()) + } + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want empty", got) + } + }) + } +} + func TestInitDbxStillRequiresAuthForDropboxCommands(t *testing.T) { t.Setenv(envAccessToken, "") t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) diff --git a/cmd/search.go b/cmd/search.go index c1750c3..576d90d 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -98,7 +98,11 @@ func renderSearchOutput(cmd *cobra.Command, query, scope string, entries []files } input := newSearchInput(query, scope, opts) - results := newJSONMetadataOperationResults(searchJSONStatusFound, jsonMetadataListFromDropbox(entries)) + metadata, err := jsonMetadataListFromDropbox(entries) + if err != nil { + return err + } + results := newJSONMetadataOperationResults(searchJSONStatusFound, metadata) return renderJSONOperationOutput(cmd, input, results) } diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 459b8a9..5923fc5 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -314,8 +314,7 @@ "kinds": [ "deleted", "file", - "folder", - "unknown" + "folder" ], "warnings": [] }, @@ -362,8 +361,7 @@ "kinds": [ "deleted", "file", - "folder", - "unknown" + "folder" ], "warnings": [] }, @@ -394,8 +392,7 @@ "kinds": [ "deleted", "file", - "folder", - "unknown" + "folder" ], "warnings": [] }, @@ -443,8 +440,7 @@ "revision" ], "kinds": [ - "file", - "unknown" + "file" ], "warnings": [] }, @@ -461,8 +457,7 @@ "kinds": [ "deleted", "file", - "folder", - "unknown" + "folder" ], "warnings": [] }, @@ -478,8 +473,7 @@ "kinds": [ "deleted", "file", - "folder", - "unknown" + "folder" ], "warnings": [] }, diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index 66cc723..70370fc 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -5,6 +5,8 @@ These schemas describe the stable top-level JSON envelopes emitted by - `success.schema.json` validates successful command responses. - `error.schema.json` validates command error responses. +- `commands.json` documents command-specific input/result payload names, + result statuses, result kinds, and warning codes. Successful responses always include: @@ -26,5 +28,6 @@ Error responses always include: - `error.code`: stable machine-readable error code - `warnings`: machine-actionable warnings, or `[]` -Command-specific `input` and `result` payloads are documented in the README and -locked by the golden contract fixtures under `cmd/testdata/json_contract/`. +Command-specific `input` and `result` payload contracts are listed in +`commands.json` and locked by the golden contract fixtures under +`cmd/testdata/json_contract/`. diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json new file mode 100644 index 0000000..5923fc5 --- /dev/null +++ b/docs/json-schema/v1/commands.json @@ -0,0 +1,699 @@ +{ + "definitions": { + "account": [ + "account_id", + "account_type", + "disabled", + "email", + "email_verified", + "is_paired", + "is_teammate", + "locale", + "name", + "profile_photo_url", + "referral_link", + "team", + "team_member_id", + "type" + ], + "account_input": [ + "account_id" + ], + "account_name": [ + "abbreviated_name", + "display_name", + "familiar_name", + "given_name", + "surname" + ], + "account_team": [ + "id", + "member_id", + "name" + ], + "du_allocation": [ + "allocated", + "type", + "used", + "user_within_team_space_allocated", + "user_within_team_space_limit_type", + "user_within_team_space_used_cached" + ], + "du_output": [ + "allocation", + "used" + ], + "empty": [], + "get_input": [ + "recursive", + "source", + "stdout", + "target" + ], + "get_result_input": [ + "source", + "target" + ], + "ls_input": [ + "include_deleted", + "long", + "only_deleted", + "path", + "recursive", + "reverse", + "sort", + "time", + "time_format" + ], + "metadata": [ + "client_modified", + "deleted", + "id", + "path_display", + "path_lower", + "rev", + "server_modified", + "size", + "type" + ], + "mkdir_input": [ + "parents", + "path" + ], + "operation_output": [ + "command", + "input", + "ok", + "results", + "schema_version", + "warnings" + ], + "operation_result": [ + "input", + "kind", + "result", + "status" + ], + "put_input": [ + "if_exists", + "recursive", + "source", + "stdin", + "target" + ], + "put_result_input": [ + "source", + "target" + ], + "relocation_input": [ + "from_path", + "to_path" + ], + "remove_input": [ + "force", + "path", + "permanent", + "recursive" + ], + "restore_input": [ + "path", + "revision" + ], + "revs_input": [ + "long", + "path", + "time", + "time_format" + ], + "search_input": [ + "long", + "path", + "query", + "reverse", + "sort", + "time", + "time_format" + ], + "share_folder": [ + "access_inheritance", + "access_type", + "is_inside_team_folder", + "is_team_folder", + "name", + "owner_display_names", + "parent_folder_name", + "parent_shared_folder_id", + "path_lower", + "preview_url", + "shared_folder_id", + "time_invited", + "type" + ], + "share_link_create_input": [ + "access", + "allow_download", + "audience", + "disallow_download", + "expires", + "password", + "path", + "remove_expiration" + ], + "share_link_download_input": [ + "password", + "path", + "recursive", + "target", + "url" + ], + "share_link_download_result": [ + "link", + "target" + ], + "share_link_info_input": [ + "password", + "path", + "url" + ], + "share_link_list_input": [ + "direct_only", + "path" + ], + "share_link_metadata": [ + "client_modified", + "expires", + "id", + "name", + "path_lower", + "permissions", + "rev", + "server_modified", + "size", + "type", + "url" + ], + "share_link_permissions": [ + "access_level", + "allow_comments", + "allow_download", + "can_allow_download", + "can_disallow_download", + "can_remove_expiry", + "can_remove_password", + "can_revoke", + "can_set_expiry", + "can_set_password", + "can_use_extended_sharing_controls", + "effective_audience", + "require_password", + "requested_visibility", + "resolved_visibility" + ], + "share_link_revoke_input": [ + "path", + "url" + ], + "share_link_revoke_result": [ + "link", + "url" + ], + "share_link_update_input": [ + "allow_download", + "audience", + "disallow_download", + "expires", + "password", + "remove_expiration", + "remove_password", + "url" + ], + "team_group": [ + "group_external_id", + "group_id", + "group_management_type", + "group_name", + "member_count", + "type" + ], + "team_info": [ + "name", + "num_licensed_users", + "num_provisioned_users", + "team_id", + "type" + ], + "team_member": [ + "account_id", + "email", + "email_verified", + "external_id", + "groups", + "invited_on", + "is_directory_restricted", + "joined_on", + "member_folder_id", + "membership_type", + "name", + "persistent_id", + "profile_photo_url", + "role", + "status", + "suspended_on", + "team_member_id", + "type" + ], + "team_member_add_input": [ + "email", + "first_name", + "last_name" + ], + "team_member_add_item": [ + "email", + "member", + "tag" + ], + "team_member_mutation": [ + "async_job_id", + "results", + "tag", + "type" + ], + "team_member_remove_input": [ + "email" + ], + "version": [ + "sdk_version", + "spec_version", + "version" + ] + }, + "commands": { + "account": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "account_input", + "result_input": "account_input", + "result": "account", + "statuses": [ + "found" + ], + "kinds": [ + "account" + ], + "warnings": [] + }, + "cp": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "relocation_input", + "result": "metadata", + "statuses": [ + "copied" + ], + "kinds": [ + "deleted", + "file", + "folder" + ], + "warnings": [] + }, + "du": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "du_output", + "statuses": [ + "reported" + ], + "kinds": [ + "space_usage" + ], + "warnings": [] + }, + "get": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "get_input", + "result_input": "get_result_input", + "result": "metadata", + "statuses": [ + "created", + "downloaded", + "existing" + ], + "kinds": [ + "file", + "folder" + ], + "warnings": [] + }, + "ls": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "ls_input", + "result_input": "empty", + "result": "metadata", + "statuses": [ + "listed" + ], + "kinds": [ + "deleted", + "file", + "folder" + ], + "warnings": [] + }, + "mkdir": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "mkdir_input", + "result_input": "mkdir_input", + "result": "metadata", + "statuses": [ + "created", + "existing" + ], + "kinds": [ + "folder" + ], + "warnings": [] + }, + "mv": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "relocation_input", + "result": "metadata", + "statuses": [ + "moved" + ], + "kinds": [ + "deleted", + "file", + "folder" + ], + "warnings": [] + }, + "put": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "put_input", + "result_input": "put_result_input", + "result": "metadata", + "statuses": [ + "created", + "existing", + "skipped", + "uploaded" + ], + "kinds": [ + "file", + "folder" + ], + "warnings": [ + "skipped_symlink" + ] + }, + "restore": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "restore_input", + "result_input": "restore_input", + "result": "metadata", + "statuses": [ + "restored" + ], + "kinds": [ + "file" + ], + "warnings": [] + }, + "revs": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "revs_input", + "result_input": "empty", + "result": "metadata", + "statuses": [ + "revision" + ], + "kinds": [ + "file" + ], + "warnings": [] + }, + "rm": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "remove_input", + "result": "metadata", + "statuses": [ + "deleted", + "permanently_deleted" + ], + "kinds": [ + "deleted", + "file", + "folder" + ], + "warnings": [] + }, + "search": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "search_input", + "result_input": "empty", + "result": "metadata", + "statuses": [ + "found" + ], + "kinds": [ + "deleted", + "file", + "folder" + ], + "warnings": [] + }, + "share list folder": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "share_folder", + "statuses": [ + "listed" + ], + "kinds": [ + "shared_folder" + ], + "warnings": [] + }, + "share list link": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_list_input", + "result_input": "empty", + "result": "share_link_metadata", + "statuses": [ + "listed" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [ + "deprecated_command" + ] + }, + "share-link create": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_create_input", + "result_input": "empty", + "result": "share_link_metadata", + "statuses": [ + "created", + "existing" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link download": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_download_input", + "result_input": "empty", + "result": "share_link_download_result", + "statuses": [ + "downloaded" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link info": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_info_input", + "result_input": "empty", + "result": "share_link_metadata", + "statuses": [ + "found" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link list": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_list_input", + "result_input": "empty", + "result": "share_link_metadata", + "statuses": [ + "listed" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "share-link revoke": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_revoke_input", + "result_input": "empty", + "result": "share_link_revoke_result", + "statuses": [ + "revoked" + ], + "kinds": [ + "file", + "folder", + "link", + "shared_link" + ], + "warnings": [] + }, + "share-link update": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "share_link_update_input", + "result_input": "empty", + "result": "share_link_metadata", + "statuses": [ + "updated" + ], + "kinds": [ + "file", + "folder", + "link" + ], + "warnings": [] + }, + "team add-member": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "team_member_add_input", + "result_input": "team_member_add_input", + "result": "team_member_mutation", + "statuses": [ + "added", + "completed", + "started" + ], + "kinds": [ + "team_member" + ], + "warnings": [] + }, + "team info": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "team_info", + "statuses": [ + "found" + ], + "kinds": [ + "team" + ], + "warnings": [] + }, + "team list-groups": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "team_group", + "statuses": [ + "listed" + ], + "kinds": [ + "team_group" + ], + "warnings": [] + }, + "team list-members": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "team_member", + "statuses": [ + "listed" + ], + "kinds": [ + "team_member" + ], + "warnings": [] + }, + "team remove-member": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "team_member_remove_input", + "result_input": "team_member_remove_input", + "result": "team_member_mutation", + "statuses": [ + "completed", + "removed", + "started" + ], + "kinds": [ + "team_member" + ], + "warnings": [] + }, + "version": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "version", + "statuses": [ + "reported" + ], + "kinds": [ + "version" + ], + "warnings": [] + } + } +} diff --git a/docs/json-schema/v1/error.schema.json b/docs/json-schema/v1/error.schema.json index 755bc98..3b560d2 100644 --- a/docs/json-schema/v1/error.schema.json +++ b/docs/json-schema/v1/error.schema.json @@ -47,7 +47,6 @@ "rate_limited", "dropbox_api_error", "structured_output_unsupported", - "unsupported_output_format", "unknown_command", "unknown_flag", "command_failed"