From 786a30c3287ea5214fe0ff81acb97af56d3ff7da Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Thu, 25 Jun 2026 11:49:41 -0700 Subject: [PATCH] Harden JSON output contract Stamp ok, schema_version, and command on JSON success and error envelopes. Normalize operation results so status, kind, input, and result are always present, with explicit statuses for account, du, version, rm, cp, and mv. Publish top-level JSON schema files, validate schema/error-code drift in contract tests, forbid unknown result statuses in command schemas, and document partial-success behavior for recursive and multi-target commands. --- README.md | 48 ++++- cmd/account.go | 11 +- cmd/add-member.go | 2 +- cmd/cp.go | 2 +- cmd/du.go | 9 +- cmd/info.go | 2 +- cmd/json_contract_test.go | 201 +++++++++++++++--- cmd/json_output.go | 108 ++++++++-- cmd/list-groups.go | 2 +- cmd/list-members.go | 2 +- cmd/mv.go | 2 +- cmd/output.go | 2 +- cmd/output_test.go | 38 +++- cmd/relocation_output.go | 9 +- cmd/remove-member.go | 2 +- cmd/restore.go | 2 +- cmd/rm.go | 16 +- cmd/share-list-folders.go | 3 +- cmd/share-list-links.go | 3 +- cmd/share_link_create.go | 3 +- cmd/share_link_info.go | 3 +- .../json_contract/success_outputs.json | 52 ++--- .../json_contract/success_schemas.json | 75 +++++-- cmd/version.go | 9 +- docs/json-schema/v1/README.md | 30 +++ docs/json-schema/v1/error.schema.json | 86 ++++++++ docs/json-schema/v1/success.schema.json | 86 ++++++++ 27 files changed, 669 insertions(+), 139 deletions(-) create mode 100644 docs/json-schema/v1/README.md create mode 100644 docs/json-schema/v1/error.schema.json create mode 100644 docs/json-schema/v1/success.schema.json diff --git a/README.md b/README.md index 14e32b0..816d424 100644 --- a/README.md +++ b/README.md @@ -192,10 +192,13 @@ JSON error responses use stable `error.code` values: | `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 an `input` object, a `results` array, and a `warnings` array. Result payloads are command-specific. 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 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 { + "ok": true, + "schema_version": "1", + "command": "mkdir", "input": { "path": "/new-folder", "parents": false @@ -224,9 +227,14 @@ For `cp` and `mv`, each result input object uses `from_path` and `to_path`: ```json { + "ok": true, + "schema_version": "1", + "command": "cp", "input": {}, "results": [ { + "status": "copied", + "kind": "file", "input": { "from_path": "/Reports/old.pdf", "to_path": "/Reports/copy.pdf" @@ -249,9 +257,14 @@ For commands such as `rm`, `input` uses command-specific path and flag fields: ```json { + "ok": true, + "schema_version": "1", + "command": "rm", "input": {}, "results": [ { + "status": "deleted", + "kind": "file", "input": { "path": "/old-file.txt", "permanent": false, @@ -276,6 +289,9 @@ For commands such as `rm`, `input` uses command-specific path and flag fields: ```json { + "ok": true, + "schema_version": "1", + "command": "put", "input": { "source": "README.md", "target": "/README.md", @@ -311,6 +327,9 @@ Entry-list commands such as `ls`, `search`, and `revs` use the operation-style w ```json { + "ok": true, + "schema_version": "1", + "command": "ls", "input": { "path": "/Reports", "recursive": false, @@ -324,6 +343,7 @@ Entry-list commands such as `ls`, `search`, and `revs` use the operation-style w { "status": "listed", "kind": "file", + "input": {}, "result": { "type": "file", "path_display": "/Reports/q1.pdf", @@ -342,9 +362,13 @@ Version, account, and usage commands use the operation-style wrapper with a sing ```json { + "ok": true, + "schema_version": "1", + "command": "version", "input": {}, "results": [ { + "status": "reported", "kind": "version", "input": {}, "result": { @@ -360,9 +384,13 @@ Version, account, and usage commands use the operation-style wrapper with a sing ```json { + "ok": true, + "schema_version": "1", + "command": "account", "input": {}, "results": [ { + "status": "found", "kind": "account", "input": {}, "result": { @@ -380,9 +408,13 @@ Version, account, and usage commands use the operation-style wrapper with a sing ```json { + "ok": true, + "schema_version": "1", + "command": "du", "input": {}, "results": [ { + "status": "reported", "kind": "space_usage", "input": {}, "result": { @@ -402,6 +434,9 @@ Shared-link commands use the same operation-style wrapper. `share-link create`, ```json { + "ok": true, + "schema_version": "1", + "command": "share-link create", "input": { "path": "/Reports/old.pdf" }, @@ -409,6 +444,7 @@ Shared-link commands use the same operation-style wrapper. `share-link create`, { "status": "created", "kind": "file", + "input": {}, "result": { "type": "file", "url": "https://www.dropbox.com/s/...", @@ -429,11 +465,15 @@ The legacy `share list folder` command also supports operation-style JSON. It us ```json { + "ok": true, + "schema_version": "1", + "command": "share list folder", "input": {}, "results": [ { "status": "listed", "kind": "shared_folder", + "input": {}, "result": { "type": "shared_folder", "name": "Reports", @@ -454,11 +494,15 @@ Team commands use the same operation-style wrapper. `team info` returns a single ```json { + "ok": true, + "schema_version": "1", + "command": "team list-members", "input": {}, "results": [ { "status": "listed", "kind": "team_member", + "input": {}, "result": { "type": "team_member", "team_member_id": "dbmid:...", @@ -480,6 +524,8 @@ In JSON mode, command errors are written to stdout as JSON, including errors fro ```json { "ok": false, + "schema_version": "1", + "command": "rm", "error": { "message": "path exists and is not a folder: /old-file.txt", "code": "path_conflict" diff --git a/cmd/account.go b/cmd/account.go index b81984b..c5e23ed 100644 --- a/cmd/account.go +++ b/cmd/account.go @@ -58,7 +58,10 @@ type jsonAccountTeam struct { MemberID string `json:"member_id,omitempty"` } -const accountKindAccount = "account" +const ( + accountJSONStatusFound = "found" + accountKindAccount = "account" +) // renderFullAccount prints the account details returned by GetCurrentAccount. func renderFullAccount(out io.Writer, fa *users.FullAccount) error { @@ -116,7 +119,7 @@ func account(cmd *cobra.Command, args []string) error { input := accountInput{} return out.Render(func(w io.Writer) error { return renderFullAccount(w, res) - }, newAccountOperationOutput(input, jsonFullAccount(res))) + }, withJSONCommand(cmd, newAccountOperationOutput(input, jsonFullAccount(res)))) } // Otherwise look up an account with the provided ID @@ -130,12 +133,12 @@ func account(cmd *cobra.Command, args []string) error { } return out.Render(func(w io.Writer) error { return renderBasicAccount(w, res) - }, newAccountOperationOutput(input, jsonBasicAccount(res))) + }, withJSONCommand(cmd, newAccountOperationOutput(input, jsonBasicAccount(res)))) } func newAccountOperationOutput(input accountInput, account jsonAccount) jsonOperationOutput { return newJSONOperationOutput(input, []jsonOperationResult{ - newJSONOperationResult("", accountKindAccount, input, account), + newJSONOperationResult(accountJSONStatusFound, accountKindAccount, input, account), }, nil) } diff --git a/cmd/add-member.go b/cmd/add-member.go index ab161ac..fdd6693 100644 --- a/cmd/add-member.go +++ b/cmd/add-member.go @@ -46,7 +46,7 @@ func addMember(cmd *cobra.Command, args []string) (err error) { } return commandOutput(cmd).Render(func(w io.Writer) error { return renderTeamMemberAdd(w, res) - }, teamMemberAddOperationOutput(input, res)) + }, withJSONCommand(cmd, teamMemberAddOperationOutput(input, res))) } func renderTeamMemberAdd(out io.Writer, res *team.MembersAddLaunch) error { diff --git a/cmd/cp.go b/cmd/cp.go index 19c9a3f..b8ef34e 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -71,7 +71,7 @@ func cp(cmd *cobra.Command, args []string) error { return fmt.Errorf("cp: %d error(s)", len(cpErrors)) } - return renderJSONOperationOutput(cmd, nil, relocationOperationResults(results)) + return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusCopied, results)) } // cpCmd represents the cp command diff --git a/cmd/du.go b/cmd/du.go index f311a36..dcc5f11 100644 --- a/cmd/du.go +++ b/cmd/du.go @@ -39,7 +39,10 @@ type duAllocation struct { UserWithinTeamSpaceLimitType string `json:"user_within_team_space_limit_type,omitempty"` } -const duKindSpaceUsage = "space_usage" +const ( + duJSONStatusReported = "reported" + duKindSpaceUsage = "space_usage" +) func du(cmd *cobra.Command, args []string) (err error) { dbx := usersNewFunc(config) @@ -50,7 +53,7 @@ func du(cmd *cobra.Command, args []string) (err error) { return commandOutput(cmd).Render(func(w io.Writer) error { return renderUsage(w, usage) - }, newDuOperationOutput(usage)) + }, withJSONCommand(cmd, newDuOperationOutput(usage))) } func renderUsage(out io.Writer, usage *users.SpaceUsage) error { @@ -81,7 +84,7 @@ func newDuOutput(usage *users.SpaceUsage) duOutput { func newDuOperationOutput(usage *users.SpaceUsage) jsonOperationOutput { input := duInput{} return newJSONOperationOutput(input, []jsonOperationResult{ - newJSONOperationResult("", duKindSpaceUsage, input, newDuOutput(usage)), + newJSONOperationResult(duJSONStatusReported, duKindSpaceUsage, input, newDuOutput(usage)), }, nil) } diff --git a/cmd/info.go b/cmd/info.go index 11121e5..db34797 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -32,7 +32,7 @@ func info(cmd *cobra.Command, args []string) (err error) { return commandOutput(cmd).Render(func(w io.Writer) error { return renderTeamInfo(w, res) - }, teamInfoOperationOutput(res)) + }, withJSONCommand(cmd, teamInfoOperationOutput(res))) } func renderTeamInfo(out io.Writer, res *team.TeamGetInfoResult) error { diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index e8b84c3..07f1940 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -92,12 +92,14 @@ func TestStructuredOutputGoldenSchemaAudit(t *testing.T) { } assertGoldenCommandSchemaEqual(t, command, gotSchema, wantSchema) assertGoldenCommandSchemaReferences(t, command, gotSchema, contract.Definitions) + assertGoldenCommandStatuses(t, command, gotSchema) } for command, schema := range contract.Commands { if !structuredSet[command] { t.Errorf("golden schema includes non-structured command %q", command) } assertGoldenCommandSchemaReferences(t, command, schema, contract.Definitions) + assertGoldenCommandStatuses(t, command, schema) } for command := range want { if !structuredSet[command] { @@ -141,6 +143,58 @@ func TestStructuredOutputGoldenSuccessOutputAudit(t *testing.T) { } } +func TestPublicJSONSchemaFiles(t *testing.T) { + tests := []struct { + file string + ok bool + required []string + properties []string + }{ + { + file: "../docs/json-schema/v1/success.schema.json", + ok: true, + required: []string{"ok", "schema_version", "command", "input", "results", "warnings"}, + properties: []string{"ok", "schema_version", "command", "input", "results", "warnings"}, + }, + { + file: "../docs/json-schema/v1/error.schema.json", + ok: false, + required: []string{"ok", "schema_version", "command", "error", "warnings"}, + properties: []string{"ok", "schema_version", "command", "error", "warnings"}, + }, + } + + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + schema := loadPublicJSONSchema(t, tt.file) + if schema.Schema == "" { + t.Fatalf("%s has no $schema", tt.file) + } + if schema.ID == "" { + t.Fatalf("%s has no $id", tt.file) + } + if schema.Type != "object" { + t.Fatalf("%s type = %q, want object", tt.file, schema.Type) + } + if got, want := schema.Properties["ok"].Const, tt.ok; got != want { + t.Fatalf("%s ok const = %v, want %v", tt.file, got, want) + } + if got, want := schema.Properties["schema_version"].Const, jsonSchemaVersion; got != want { + t.Fatalf("%s schema_version const = %v, want %v", tt.file, got, want) + } + assertStringSliceEqual(t, tt.file+" required", schema.Required, tt.required) + assertStringSliceEqual(t, tt.file+" properties", mapKeys(schema.Properties), tt.properties) + if tt.ok { + assertStringSliceEqual(t, tt.file+" result required", schema.Defs["result"].Required, []string{"status", "kind", "input", "result"}) + } else { + errorSchema := schema.Properties["error"] + codeSchema := errorSchema.Properties["code"] + assertStringSliceEqual(t, tt.file+" error code enum", codeSchema.Enum, expectedJSONErrorCodes()) + } + }) + } +} + func structuredOutputCommandPathsWithVersion() []string { paths := structuredOutputCommandPaths(RootCmd) return append(paths, NewVersionCommand("test").Name()) @@ -198,6 +252,21 @@ type jsonGoldenCommandSchema struct { Warnings []string `json:"warnings"` } +type publicJSONSchema struct { + Schema string `json:"$schema"` + ID string `json:"$id"` + Type string `json:"type"` + Required []string `json:"required"` + Properties map[string]publicJSONSchemaProperty `json:"properties"` + Defs map[string]publicJSONSchema `json:"$defs"` +} + +type publicJSONSchemaProperty struct { + Const any `json:"const"` + Enum []string `json:"enum"` + Properties map[string]publicJSONSchemaProperty `json:"properties"` +} + func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture { return map[string]jsonSuccessFixture{ "account": { @@ -372,6 +441,41 @@ func loadJSONGoldenSuccessOutputs(t *testing.T) map[string]json.RawMessage { return fixtures } +func loadPublicJSONSchema(t *testing.T, file string) publicJSONSchema { + t.Helper() + + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read public JSON schema %s: %v", file, err) + } + + var schema publicJSONSchema + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatalf("decode public JSON schema %s: %v", file, err) + } + return schema +} + +func expectedJSONErrorCodes() []string { + return []string{ + jsonErrorCodeAppKeyRequired, + jsonErrorCodeAuthExchangeFailed, + jsonErrorCodeAuthRefreshFailed, + jsonErrorCodeAuthRequired, + jsonErrorCodeCommandFailed, + jsonErrorCodeDropboxAPIError, + jsonErrorCodeInvalidArguments, + jsonErrorCodeNotFound, + jsonErrorCodePathConflict, + jsonErrorCodePermissionDenied, + jsonErrorCodeRateLimited, + jsonErrorCodeStructuredOutputUnsupported, + jsonErrorCodeUnknownCommand, + jsonErrorCodeUnknownFlag, + jsonErrorCodeUnsupportedOutputFormat, + } +} + func assertGoldenJSONEqual(t *testing.T, command string, fixture json.RawMessage, actual any) { t.Helper() @@ -404,15 +508,15 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { sharedLink := sampleShareLinkJSONMetadata() teamMember := sampleTeamMemberJSON() - return map[string]jsonOperationOutput{ + examples := map[string]jsonOperationOutput{ "account": newJSONOperationOutput(accountInput{AccountID: "dbid:lookup"}, []jsonOperationResult{ - newJSONOperationResult("", accountKindAccount, accountInput{AccountID: "dbid:lookup"}, sampleJSONAccount()), + newJSONOperationResult(accountJSONStatusFound, accountKindAccount, accountInput{AccountID: "dbid:lookup"}, sampleJSONAccount()), }, nil), "cp": newJSONOperationOutput(nil, []jsonOperationResult{ - newJSONOperationResult("", "", relocationInput{FromPath: "/Reports/old.pdf", ToPath: "/Reports/copy.pdf"}, copyFile), + newJSONOperationResult(relocationJSONStatusCopied, copyFile.Type, relocationInput{FromPath: "/Reports/old.pdf", ToPath: "/Reports/copy.pdf"}, copyFile), }, nil), "du": newJSONOperationOutput(duInput{}, []jsonOperationResult{ - newJSONOperationResult("", duKindSpaceUsage, duInput{}, duOutput{ + newJSONOperationResult(duJSONStatusReported, duKindSpaceUsage, duInput{}, duOutput{ Used: 2048, Allocation: duAllocation{ Type: "team", @@ -434,7 +538,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { newJSONOperationResult(mkdirStatusCreated, mkdirKindFolder, mkdirInput{Path: "/Reports/new", Parents: true}, sampleJSONFolderMetadata("/Reports/new")), }, nil), "mv": newJSONOperationOutput(nil, []jsonOperationResult{ - newJSONOperationResult("", "", relocationInput{FromPath: "/Reports/copy.pdf", ToPath: "/Reports/moved.pdf"}, sampleJSONFileMetadata("/Reports/moved.pdf")), + newJSONOperationResult(relocationJSONStatusMoved, "file", relocationInput{FromPath: "/Reports/copy.pdf", ToPath: "/Reports/moved.pdf"}, sampleJSONFileMetadata("/Reports/moved.pdf")), }, nil), "put": newJSONOperationOutput(putCommandInput{Source: "README.md", Target: "/README.md", Recursive: true, IfExists: putIfExistsOverwrite, Stdin: false}, []jsonOperationResult{ newJSONOperationResult(putStatusUploaded, putKindFile, putResultInput{Source: "README.md", Target: "/README.md"}, sampleJSONFileMetadata("/README.md")), @@ -446,7 +550,7 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { newJSONOperationResult(revsJSONStatusRevision, file.Type, nil, file), }, nil), "rm": newJSONOperationOutput(nil, []jsonOperationResult{ - newJSONOperationResult("", "", removeInput{Path: "/Reports/old.pdf", Permanent: false, Recursive: false, Force: false}, file), + newJSONOperationResult(removeJSONStatusDeleted, file.Type, removeInput{Path: "/Reports/old.pdf", Permanent: false, Recursive: false, Force: false}, file), }, nil), "search": newJSONOperationOutput(searchInput{Query: "report", Path: "/Reports", Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ newJSONOperationResult(searchJSONStatusFound, folder.Type, nil, folder), @@ -499,9 +603,16 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { newJSONOperationResult(teamJSONStatusRemoved, teamJSONKindTeamMember, teamMemberRemoveInput{Email: "ada@example.com"}, teamMemberMutationJSON{Type: teamJSONTypeMemberRemove, Tag: "complete", AsyncJobID: "async-job-id"}), }, nil), "version": newJSONOperationOutput(versionInput{}, []jsonOperationResult{ - newJSONOperationResult("", versionKindVersion, versionInput{}, versionOutput{Version: "1.2.3", SDKVersion: "sdk-version", SpecVersion: "spec-version"}), + newJSONOperationResult(versionJSONStatusReported, versionKindVersion, versionInput{}, versionOutput{Version: "1.2.3", SDKVersion: "sdk-version", SpecVersion: "spec-version"}), }, nil), } + for command, example := range examples { + example.OK = true + example.SchemaVersion = jsonSchemaVersion + example.Command = command + examples[command] = example + } + return examples } func sampleJSONAccount() jsonAccount { @@ -669,39 +780,39 @@ func jsonContractDefinitions() map[string][]string { func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { return map[string]jsonGoldenCommandSchema{ - "account": operationSchema("account_input", schemaRef("account_input"), "account", nil, []string{accountKindAccount}, nil), - "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", nil, nil, nil), - "du": operationSchema("empty", schemaRef("empty"), "du_output", nil, []string{duKindSpaceUsage}, nil), + "account": operationSchema("account_input", schemaRef("account_input"), "account", []string{accountJSONStatusFound}, []string{accountKindAccount}, nil), + "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusCopied}, metadataKinds(), nil), + "du": operationSchema("empty", schemaRef("empty"), "du_output", []string{duJSONStatusReported}, []string{duKindSpaceUsage}, nil), "get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil), - "ls": operationSchema("ls_input", nil, "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil), + "ls": operationSchema("ls_input", schemaRef("empty"), "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil), "mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting}, []string{mkdirKindFolder}, nil), - "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", nil, nil, nil), + "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", nil, "metadata", []string{revsJSONStatusRevision}, []string{"file", "unknown"}, nil), - "rm": operationSchema("empty", schemaRef("remove_input"), "metadata", nil, nil, nil), - "search": operationSchema("search_input", nil, "metadata", []string{searchJSONStatusFound}, metadataKinds(), nil), - "share list folder": operationSchema("empty", nil, "share_folder", []string{shareFolderJSONStatusListed}, []string{shareFolderJSONKindFolder}, nil), - "share list link": operationSchema("share_link_list_input", nil, "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), []string{jsonWarningCodeDeprecatedCommand}), - "share-link create": operationSchema("share_link_create_input", nil, "share_link_metadata", []string{shareLinkJSONStatusCreated, shareLinkJSONStatusExisting}, shareLinkKinds(), nil), + "revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file", "unknown"}, 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), + "share list link": operationSchema("share_link_list_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), []string{jsonWarningCodeDeprecatedCommand}), + "share-link create": operationSchema("share_link_create_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusCreated, shareLinkJSONStatusExisting}, shareLinkKinds(), nil), "share-link download": operationSchema( "share_link_download_input", - nil, + schemaRef("empty"), "share_link_download_result", []string{shareLinkJSONStatusDownloaded}, shareLinkKinds(), nil, ), - "share-link info": operationSchema("share_link_info_input", nil, "share_link_metadata", []string{shareLinkJSONStatusFound}, shareLinkKinds(), nil), - "share-link list": operationSchema("share_link_list_input", nil, "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), nil), - "share-link revoke": operationSchema("share_link_revoke_input", nil, "share_link_revoke_result", []string{shareLinkJSONStatusRevoked}, append(shareLinkKinds(), shareLinkJSONKindSharedLink), nil), - "share-link update": operationSchema("share_link_update_input", nil, "share_link_metadata", []string{shareLinkJSONStatusUpdated}, shareLinkKinds(), nil), + "share-link info": operationSchema("share_link_info_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusFound}, shareLinkKinds(), nil), + "share-link list": operationSchema("share_link_list_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), nil), + "share-link revoke": operationSchema("share_link_revoke_input", schemaRef("empty"), "share_link_revoke_result", []string{shareLinkJSONStatusRevoked}, append(shareLinkKinds(), shareLinkJSONKindSharedLink), nil), + "share-link update": operationSchema("share_link_update_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusUpdated}, shareLinkKinds(), nil), "team add-member": operationSchema("team_member_add_input", schemaRef("team_member_add_input"), "team_member_mutation", []string{teamJSONStatusAdded, teamJSONStatusCompleted, teamJSONStatusStarted}, []string{teamJSONKindTeamMember}, nil), "team info": operationSchema("empty", schemaRef("empty"), "team_info", []string{teamJSONStatusFound}, []string{teamJSONKindTeam}, nil), - "team list-groups": operationSchema("empty", nil, "team_group", []string{teamJSONStatusListed}, []string{teamJSONKindTeamGroup}, nil), - "team list-members": operationSchema("empty", nil, "team_member", []string{teamJSONStatusListed}, []string{teamJSONKindTeamMember}, nil), + "team list-groups": operationSchema("empty", schemaRef("empty"), "team_group", []string{teamJSONStatusListed}, []string{teamJSONKindTeamGroup}, nil), + "team list-members": operationSchema("empty", schemaRef("empty"), "team_member", []string{teamJSONStatusListed}, []string{teamJSONKindTeamMember}, nil), "team remove-member": operationSchema("team_member_remove_input", schemaRef("team_member_remove_input"), "team_member_mutation", []string{teamJSONStatusCompleted, teamJSONStatusRemoved, teamJSONStatusStarted}, []string{teamJSONKindTeamMember}, nil), - "version": operationSchema("empty", schemaRef("empty"), "version", nil, []string{versionKindVersion}, nil), + "version": operationSchema("empty", schemaRef("empty"), "version", []string{versionJSONStatusReported}, []string{versionKindVersion}, nil), } } @@ -772,6 +883,17 @@ func assertStringSliceMapEqual(t *testing.T, label string, got, want map[string] t.Fatalf("%s = %s, want %s", label, gotJSON, wantJSON) } +func assertStringSliceEqual(t *testing.T, label string, got, want []string) { + t.Helper() + + got = sortedCopy(got) + want = sortedCopy(want) + if reflect.DeepEqual(got, want) { + return + } + t.Fatalf("%s = %v, want %v", label, got, want) +} + func assertGoldenCommandSchemaEqual(t *testing.T, command string, got, want jsonGoldenCommandSchema) { t.Helper() @@ -800,6 +922,19 @@ func assertGoldenCommandSchemaReferences(t *testing.T, command string, schema js } } +func assertGoldenCommandStatuses(t *testing.T, command string, schema jsonGoldenCommandSchema) { + t.Helper() + + if len(schema.Statuses) == 0 { + t.Errorf("golden schema for %q has no result statuses", command) + } + for _, status := range schema.Statuses { + if status == "unknown" { + t.Errorf("golden schema for %q must not allow unknown result status", command) + } + } +} + func normalizeGoldenContract(contract jsonGoldenContract) jsonGoldenContract { contract.Definitions = normalizeStringSliceMap(contract.Definitions) commands := make(map[string]jsonGoldenCommandSchema, len(contract.Commands)) @@ -834,6 +969,14 @@ func sortedCopy(values []string) []string { return copied } +func mapKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + func structuredOutputCommandPaths(root *cobra.Command) []string { var paths []string var walk func(*cobra.Command, []string) @@ -863,13 +1006,13 @@ func TestJSONOperationOutputContractShape(t *testing.T) { if err := json.Unmarshal(encoded, &raw); err != nil { t.Fatalf("decode operation output: %v", err) } - for _, key := range []string{"input", "results", "warnings"} { + for _, key := range []string{"ok", "schema_version", "command", "input", "results", "warnings"} { if _, ok := raw[key]; !ok { t.Fatalf("operation output = %s, missing %q", encoded, key) } } - if len(raw) != 3 { - t.Fatalf("operation output = %s, want only input/results/warnings", encoded) + if len(raw) != 6 { + t.Fatalf("operation output = %s, want only ok/schema_version/command/input/results/warnings", encoded) } } diff --git a/cmd/json_output.go b/cmd/json_output.go index 85486ec..147a1ad 100644 --- a/cmd/json_output.go +++ b/cmd/json_output.go @@ -1,11 +1,17 @@ package cmd -import "github.com/spf13/cobra" +import ( + "strings" + + "github.com/spf13/cobra" +) type jsonErrorResponse struct { - OK bool `json:"ok"` - Error jsonError `json:"error"` - Warnings []jsonWarning `json:"warnings"` + OK bool `json:"ok"` + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Error jsonError `json:"error"` + Warnings []jsonWarning `json:"warnings"` } type jsonError struct { @@ -25,21 +31,28 @@ const ( ) type jsonOperationOutput struct { - Input any `json:"input"` - Results []jsonOperationResult `json:"results"` - Warnings []jsonWarning `json:"warnings"` + OK bool `json:"ok"` + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Input any `json:"input"` + Results []jsonOperationResult `json:"results"` + Warnings []jsonWarning `json:"warnings"` } type jsonOperationResult struct { - Status string `json:"status,omitempty"` - Kind string `json:"kind,omitempty"` - Input any `json:"input,omitempty"` - Result any `json:"result,omitempty"` + Status string `json:"status"` + Kind string `json:"kind"` + Input any `json:"input"` + Result any `json:"result"` } -func newJSONErrorResponse(err error) jsonErrorResponse { +const jsonSchemaVersion = "1" + +func newJSONErrorResponse(cmd *cobra.Command, err error) jsonErrorResponse { return jsonErrorResponse{ - OK: false, + OK: false, + SchemaVersion: jsonSchemaVersion, + Command: jsonCommandPath(cmd), Error: jsonError{ Message: err.Error(), Code: jsonErrorCode(err), @@ -50,27 +63,40 @@ func newJSONErrorResponse(err error) jsonErrorResponse { func newJSONOperationOutput(input any, results []jsonOperationResult, warnings []jsonWarning) jsonOperationOutput { return jsonOperationOutput{ - Input: normalizeJSONInput(input), - Results: normalizeJSONOperationResults(results), - Warnings: normalizeJSONWarnings(warnings), + OK: true, + SchemaVersion: jsonSchemaVersion, + Input: normalizeJSONInput(input), + Results: normalizeJSONOperationResults(results), + Warnings: normalizeJSONWarnings(warnings), } } +func newJSONCommandOperationOutput(cmd *cobra.Command, input any, results []jsonOperationResult, warnings []jsonWarning) jsonOperationOutput { + return withJSONCommand(cmd, newJSONOperationOutput(input, results, warnings)) +} + +func withJSONCommand(cmd *cobra.Command, out jsonOperationOutput) jsonOperationOutput { + out.OK = true + out.SchemaVersion = jsonSchemaVersion + out.Command = jsonCommandPath(cmd) + return out +} + func renderJSONOperationOutput(cmd *cobra.Command, input any, results []jsonOperationResult) error { return renderJSONOperationOutputWithWarnings(cmd, input, results, nil) } func renderJSONOperationOutputWithWarnings(cmd *cobra.Command, input any, results []jsonOperationResult, warnings []jsonWarning) error { - return commandOutput(cmd).Render(nil, newJSONOperationOutput(input, results, warnings)) + return commandOutput(cmd).Render(nil, newJSONCommandOperationOutput(cmd, input, results, warnings)) } func newJSONOperationResult(status, kind string, input any, result any) jsonOperationResult { - return jsonOperationResult{ + return normalizeJSONOperationResult(jsonOperationResult{ Status: status, Kind: kind, Input: input, Result: result, - } + }) } func newJSONMetadataOperationResults(status string, entries []jsonMetadata) []jsonOperationResult { @@ -82,19 +108,38 @@ func newJSONMetadataOperationResults(status string, entries []jsonMetadata) []js } func normalizeJSONInput(input any) any { - if input == nil { - return struct{}{} - } - return input + return normalizeJSONObject(input) } func normalizeJSONOperationResults(results []jsonOperationResult) []jsonOperationResult { if results == nil { return []jsonOperationResult{} } + for i := range results { + results[i] = normalizeJSONOperationResult(results[i]) + } return results } +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 +} + +func normalizeJSONObject(value any) any { + if value == nil { + return struct{}{} + } + return value +} + func emptyJSONWarnings() []jsonWarning { return []jsonWarning{} } @@ -105,3 +150,20 @@ func normalizeJSONWarnings(warnings []jsonWarning) []jsonWarning { } return warnings } + +func jsonCommandPath(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + if cmd.Parent() == nil { + return cmd.Name() + } + + var parts []string + for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() { + if name := c.Name(); name != "" { + parts = append([]string{name}, parts...) + } + } + return strings.Join(parts, " ") +} diff --git a/cmd/list-groups.go b/cmd/list-groups.go index 620de4b..31ce0a6 100644 --- a/cmd/list-groups.go +++ b/cmd/list-groups.go @@ -37,7 +37,7 @@ func listGroups(cmd *cobra.Command, args []string) (err error) { return commandOutput(cmd).Render(func(w io.Writer) error { return renderTeamGroups(w, groups) - }, newJSONOperationOutput(teamInfoInput{}, teamGroupOperationResults(groups), nil)) + }, newJSONCommandOperationOutput(cmd, teamInfoInput{}, teamGroupOperationResults(groups), nil)) } func listTeamGroups(dbx teamClient, arg *team.GroupsListArg) ([]*team_common.GroupSummary, error) { diff --git a/cmd/list-members.go b/cmd/list-members.go index 1804f54..699ef75 100644 --- a/cmd/list-members.go +++ b/cmd/list-members.go @@ -36,7 +36,7 @@ func listMembers(cmd *cobra.Command, args []string) (err error) { return commandOutput(cmd).Render(func(w io.Writer) error { return renderTeamMembers(w, members) - }, newJSONOperationOutput(teamInfoInput{}, teamMemberOperationResults(members), nil)) + }, newJSONCommandOperationOutput(cmd, teamInfoInput{}, teamMemberOperationResults(members), nil)) } func listTeamMembers(dbx teamClient, arg *team.MembersListArg) ([]*team.TeamMemberInfo, error) { diff --git a/cmd/mv.go b/cmd/mv.go index 74bbb9a..f3a481c 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -70,7 +70,7 @@ func mv(cmd *cobra.Command, args []string) error { return fmt.Errorf("mv: %d error(s)", len(mvErrors)) } - return renderJSONOperationOutput(cmd, nil, relocationOperationResults(results)) + return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusMoved, results)) } // mvCmd represents the mv command diff --git a/cmd/output.go b/cmd/output.go index 4e4b9f4..2fd230b 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -213,7 +213,7 @@ func renderCommandErrorWithJSON(cmd *cobra.Command, err error, forceJSON bool) { } if forceJSON || commandOutputFormat(cmd) == output.FormatJSON { - renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, newJSONErrorResponse(err)) + renderErr := output.New(cmd.OutOrStdout(), cmd.ErrOrStderr(), output.FormatJSON).Render(nil, newJSONErrorResponse(cmd, err)) if renderErr == nil { return } diff --git a/cmd/output_test.go b/cmd/output_test.go index 4303d30..86b6e9c 100644 --- a/cmd/output_test.go +++ b/cmd/output_test.go @@ -278,7 +278,7 @@ func TestRenderCommandErrorTextUnknownCommandIncludesUsageHint(t *testing.T) { func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - cmd := &cobra.Command{} + cmd := &cobra.Command{Use: "rm"} cmd.SetOut(&stdout) cmd.SetErr(&stderr) cmd.Flags().String(outputFlag, "json", "") @@ -293,6 +293,12 @@ func TestRenderCommandErrorWritesJSONErrorToStdout(t *testing.T) { if got.OK { t.Fatalf("ok = true, want false") } + if got.SchemaVersion != jsonSchemaVersion { + t.Fatalf("schema_version = %q, want %q", got.SchemaVersion, jsonSchemaVersion) + } + if got.Command != "rm" { + t.Fatalf("command = %q, want rm", got.Command) + } if got.Error.Message != "failed" { t.Fatalf("message = %q, want failed", got.Error.Message) } @@ -406,7 +412,9 @@ func TestNewJSONOperationOutputNormalizesResults(t *testing.T) { func TestRenderJSONOperationOutput(t *testing.T) { var stdout bytes.Buffer - cmd := &cobra.Command{} + root := &cobra.Command{Use: "dbxcli"} + cmd := &cobra.Command{Use: "get"} + root.AddCommand(cmd) cmd.SetOut(&stdout) cmd.Flags().String(outputFlag, "json", "") @@ -434,6 +442,9 @@ func TestRenderJSONOperationOutput(t *testing.T) { rendered := stdout.String() for _, want := range []string{ + `"ok":true`, + `"schema_version":"1"`, + `"command":"get"`, `"input":{"path":"/file.txt"}`, `"results":[{"status":"downloaded","kind":"file"`, `"warnings":[]`, @@ -444,7 +455,7 @@ func TestRenderJSONOperationOutput(t *testing.T) { } } -func TestNewJSONOperationResultOmitsEmptyFields(t *testing.T) { +func TestNewJSONOperationResultNormalizesEmptyFields(t *testing.T) { got := newJSONOperationResult( "", "", @@ -459,14 +470,16 @@ func TestNewJSONOperationResultOmitsEmptyFields(t *testing.T) { t.Fatalf("marshal operation result: %v", err) } rendered := string(encoded) - for _, unwanted := range []string{`"status"`, `"kind"`, `"result"`} { - if strings.Contains(rendered, unwanted) { - t.Fatalf("encoded output = %s, did not expect %s", rendered, unwanted) + for _, want := range []string{ + `"status":"unknown"`, + `"kind":"unknown"`, + `"input":{"path":"/file.txt"}`, + `"result":{}`, + } { + if !strings.Contains(rendered, want) { + t.Fatalf("encoded output = %s, want %s", rendered, want) } } - if !strings.Contains(rendered, `"input":{"path":"/file.txt"}`) { - t.Fatalf("encoded output = %s, want input", rendered) - } } func TestNewJSONMetadataOperationResults(t *testing.T) { @@ -486,8 +499,11 @@ func TestNewJSONMetadataOperationResults(t *testing.T) { if len(results) != 2 { t.Fatalf("results = %d, want 2", len(results)) } - if results[0].Status != "listed" || results[0].Kind != "file" || results[0].Input != nil { - t.Fatalf("first result = %#v, want listed file with no per-result input", results[0]) + if results[0].Status != "listed" || results[0].Kind != "file" { + t.Fatalf("first result = %#v, want listed file", results[0]) + } + if _, ok := results[0].Input.(struct{}); !ok { + t.Fatalf("first result input = %#v, want empty object", results[0].Input) } first, ok := results[0].Result.(jsonMetadata) if !ok { diff --git a/cmd/relocation_output.go b/cmd/relocation_output.go index 8d9b08e..86b2dcb 100644 --- a/cmd/relocation_output.go +++ b/cmd/relocation_output.go @@ -2,6 +2,11 @@ package cmd import "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" +const ( + relocationJSONStatusCopied = "copied" + relocationJSONStatusMoved = "moved" +) + type relocationInput struct { FromPath string `json:"from_path"` ToPath string `json:"to_path"` @@ -27,10 +32,10 @@ func newRelocationResult(arg *files.RelocationArg, res *files.RelocationResult) } } -func relocationOperationResults(results []relocationResult) []jsonOperationResult { +func relocationOperationResults(status string, results []relocationResult) []jsonOperationResult { operationResults := make([]jsonOperationResult, 0, len(results)) for _, result := range results { - operationResults = append(operationResults, newJSONOperationResult("", "", result.Input, result.Result)) + operationResults = append(operationResults, newJSONOperationResult(status, result.Result.Type, result.Input, result.Result)) } return operationResults } diff --git a/cmd/remove-member.go b/cmd/remove-member.go index d1c384d..98c2db7 100644 --- a/cmd/remove-member.go +++ b/cmd/remove-member.go @@ -40,7 +40,7 @@ func removeMember(cmd *cobra.Command, args []string) (err error) { input := teamMemberRemoveInput{Email: email} return commandOutput(cmd).Render(func(w io.Writer) error { return renderTeamMemberRemove(w, res) - }, teamMemberRemoveOperationOutput(input, res)) + }, withJSONCommand(cmd, teamMemberRemoveOperationOutput(input, res))) } func renderTeamMemberRemove(out io.Writer, res *async.LaunchEmptyResult) error { diff --git a/cmd/restore.go b/cmd/restore.go index 1100f2d..3d3146a 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -68,7 +68,7 @@ func restore(cmd *cobra.Command, args []string) (err error) { return nil } return renderRestoreResult(w, result) - }, newJSONOperationOutput(result.Input, []jsonOperationResult{restoreOperationResult(result)}, nil)) + }, newJSONCommandOperationOutput(cmd, result.Input, []jsonOperationResult{restoreOperationResult(result)}, nil)) } func newRestoreResult(path, revision string, metadata *files.FileMetadata) restoreResult { diff --git a/cmd/rm.go b/cmd/rm.go index 741f49f..f666991 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -47,6 +47,11 @@ type removeResult struct { Result jsonMetadata `json:"result"` } +const ( + removeJSONStatusDeleted = "deleted" + removeJSONStatusPermanentlyDeleted = "permanently_deleted" +) + func rm(cmd *cobra.Command, args []string) error { if len(args) < 1 { return invalidArgumentsError("rm: missing operand") @@ -74,17 +79,24 @@ func rm(cmd *cobra.Command, args []string) error { return nil } return renderRemoveResults(w, results) - }, newJSONOperationOutput(nil, removeOperationResults(results), nil)) + }, newJSONCommandOperationOutput(cmd, nil, removeOperationResults(results), nil)) } func removeOperationResults(results []removeResult) []jsonOperationResult { operationResults := make([]jsonOperationResult, 0, len(results)) for _, result := range results { - operationResults = append(operationResults, newJSONOperationResult("", "", result.Input, result.Result)) + operationResults = append(operationResults, newJSONOperationResult(removeJSONStatus(result), result.Result.Type, result.Input, result.Result)) } return operationResults } +func removeJSONStatus(result removeResult) string { + if result.Input.Permanent { + return removeJSONStatusPermanentlyDeleted + } + return removeJSONStatusDeleted +} + func parseRemoveOptions(cmd *cobra.Command) (removeOptions, error) { force, err := cmd.Flags().GetBool("force") if err != nil { diff --git a/cmd/share-list-folders.go b/cmd/share-list-folders.go index 4b6bb00..99d0e08 100644 --- a/cmd/share-list-folders.go +++ b/cmd/share-list-folders.go @@ -68,7 +68,8 @@ func shareListFolders(cmd *cobra.Command, args []string) (err error) { return commandOutput(cmd).Render(func(w io.Writer) error { return renderSharedFolders(w, entries) - }, newJSONOperationOutput( + }, newJSONCommandOperationOutput( + cmd, shareFolderListInput{}, shareFolderJSONOperationResults(shareFolderJSONMetadataListFromDropbox(entries)), nil, diff --git a/cmd/share-list-links.go b/cmd/share-list-links.go index 95623bc..063e53d 100644 --- a/cmd/share-list-links.go +++ b/cmd/share-list-links.go @@ -73,7 +73,8 @@ func shareLinkListWithWarnings(cmd *cobra.Command, args []string, warnings []jso return commandOutput(cmd).Render(func(w io.Writer) error { return renderSharedLinks(w, links) - }, newJSONOperationOutput( + }, newJSONCommandOperationOutput( + cmd, shareLinkListInput{ Path: arg.Path, DirectOnly: arg.DirectOnly, diff --git a/cmd/share_link_create.go b/cmd/share_link_create.go index 6322ac5..938aa9d 100644 --- a/cmd/share_link_create.go +++ b/cmd/share_link_create.go @@ -105,7 +105,8 @@ func shareLinkCreate(cmd *cobra.Command, args []string) error { return out.Render(func(w io.Writer) error { _, err := fmt.Fprintln(w, url) return err - }, newJSONOperationOutput( + }, newJSONCommandOperationOutput( + cmd, newShareLinkCreateInput(path, opts), []jsonOperationResult{shareLinkJSONOperationResult(status, result)}, nil, diff --git a/cmd/share_link_info.go b/cmd/share_link_info.go index e452469..453cef8 100644 --- a/cmd/share_link_info.go +++ b/cmd/share_link_info.go @@ -73,7 +73,8 @@ func shareLinkInfo(cmd *cobra.Command, args []string) error { return commandOutput(cmd).Render(func(w io.Writer) error { return renderSharedLinkInfo(w, link) - }, newJSONOperationOutput( + }, newJSONCommandOperationOutput( + cmd, shareLinkInfoInput{ URL: url, Path: opts.path, diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index ec87a4e..38e8c55 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -1,28 +1,28 @@ { - "account": {"input":{"account_id":"dbid:lookup"},"results":[{"kind":"account","input":{"account_id":"dbid:lookup"},"result":{"type":"full","account_id":"dbid:account","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"email":"ada@example.com","email_verified":true,"disabled":false,"profile_photo_url":"https://example.com/profile.jpg","locale":"en","referral_link":"https://example.com/referral","is_paired":false,"account_type":"basic","is_teammate":true,"team_member_id":"dbmid:team-member","team":{"id":"team-id","name":"Engineering","member_id":"dbmid:team-member"}}}],"warnings":[]}, - "cp": {"input":{},"results":[{"input":{"from_path":"/Reports/old.pdf","to_path":"/Reports/copy.pdf"},"result":{"type":"file","path_display":"/Reports/copy.pdf","path_lower":"/reports/copy.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "du": {"input":{},"results":[{"kind":"space_usage","input":{},"result":{"used":2048,"allocation":{"type":"team","allocated":1000000,"used":2048,"user_within_team_space_allocated":500000,"user_within_team_space_used_cached":1024,"user_within_team_space_limit_type":"fixed"}}}],"warnings":[]}, - "get": {"input":{"source":"/Reports/old.pdf","target":"old.pdf","recursive":false,"stdout":false},"results":[{"status":"downloaded","kind":"file","input":{"source":"/Reports/old.pdf","target":"old.pdf"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "ls": {"input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "mkdir": {"input":{"path":"/Reports/new","parents":true},"results":[{"status":"created","kind":"folder","input":{"path":"/Reports/new","parents":true},"result":{"type":"folder","path_display":"/Reports/new","path_lower":"/reports/new","id":"id:folder"}}],"warnings":[]}, - "mv": {"input":{},"results":[{"input":{"from_path":"/Reports/copy.pdf","to_path":"/Reports/moved.pdf"},"result":{"type":"file","path_display":"/Reports/moved.pdf","path_lower":"/reports/moved.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "put": {"input":{"source":"README.md","target":"/README.md","recursive":true,"if_exists":"overwrite","stdin":false},"results":[{"status":"uploaded","kind":"file","input":{"source":"README.md","target":"/README.md"},"result":{"type":"file","path_display":"/README.md","path_lower":"/readme.md","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[{"code":"skipped_symlink","message":"skipped symlink","path":"docs/link"}]}, - "restore": {"input":{"path":"/Reports/old.pdf","revision":"015f"},"results":[{"status":"restored","kind":"file","input":{"path":"/Reports/old.pdf","revision":"015f"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "revs": {"input":{"path":"/Reports/old.pdf","long":true,"time":"server","time_format":"2006-01-02"},"results":[{"status":"revision","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "rm": {"input":{},"results":[{"input":{"path":"/Reports/old.pdf","permanent":false,"recursive":false,"force":false},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, - "search": {"input":{"query":"report","path":"/Reports","long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"}}],"warnings":[]}, - "share list folder": {"input":{},"results":[{"status":"listed","kind":"shared_folder","result":{"type":"shared_folder","name":"Reports","path_lower":"/reports","shared_folder_id":"sfid:reports","preview_url":"https://www.dropbox.com/preview","access_type":"owner","is_inside_team_folder":false,"is_team_folder":true,"owner_display_names":["Ada Lovelace"],"parent_shared_folder_id":"sfid:parent","parent_folder_name":"Parent","time_invited":"2026-06-25T10:00:00Z","access_inheritance":"inherit"}}],"warnings":[]}, - "share list link": {"input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[{"code":"deprecated_command","message":"use `dbxcli share-link list` instead"}]}, - "share-link create": {"input":{"path":"/Reports/old.pdf","access":"viewer","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, - "share-link download": {"input":{"url":"https://www.dropbox.com/s/example/old.pdf","target":"old.pdf","path":"/old.pdf","password":true},"results":[{"status":"downloaded","kind":"file","result":{"target":"old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}}],"warnings":[]}, - "share-link info": {"input":{"url":"https://www.dropbox.com/s/example/old.pdf","path":"/old.pdf","password":true},"results":[{"status":"found","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, - "share-link list": {"input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, - "share-link revoke": {"input":{"path":"/Reports/old.pdf"},"results":[{"status":"revoked","kind":"file","result":{"url":"https://www.dropbox.com/s/example/old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}}],"warnings":[]}, - "share-link update": {"input":{"url":"https://www.dropbox.com/s/example/old.pdf","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"updated","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}}],"warnings":[]}, - "team add-member": {"input":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace"},"results":[{"status":"added","kind":"team_member","input":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace"},"result":{"type":"team_member_add","tag":"complete","results":[{"tag":"success","email":"ada@example.com","member":{"type":"team_member","team_member_id":"dbmid:team-member","external_id":"external-member","account_id":"dbid:account","email":"ada@example.com","email_verified":true,"status":"active","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"role":"member_only","groups":["g:dev"],"member_folder_id":"ns:member-folder","membership_type":"full","invited_on":"2026-06-24T12:00:00Z","joined_on":"2026-06-25T12:00:00Z","suspended_on":"2026-06-26T12:00:00Z","persistent_id":"persistent-id","is_directory_restricted":true,"profile_photo_url":"https://example.com/member.jpg"}}]}}],"warnings":[]}, - "team info": {"input":{},"results":[{"status":"found","kind":"team","input":{},"result":{"type":"team","name":"Engineering","team_id":"team-id","num_licensed_users":10,"num_provisioned_users":8}}],"warnings":[]}, - "team list-groups": {"input":{},"results":[{"status":"listed","kind":"team_group","result":{"type":"team_group","group_name":"Developers","group_id":"g:dev","group_external_id":"external-dev","member_count":3,"group_management_type":"company_managed"}}],"warnings":[]}, - "team list-members": {"input":{},"results":[{"status":"listed","kind":"team_member","result":{"type":"team_member","team_member_id":"dbmid:team-member","external_id":"external-member","account_id":"dbid:account","email":"ada@example.com","email_verified":true,"status":"active","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"role":"member_only","groups":["g:dev"],"member_folder_id":"ns:member-folder","membership_type":"full","invited_on":"2026-06-24T12:00:00Z","joined_on":"2026-06-25T12:00:00Z","suspended_on":"2026-06-26T12:00:00Z","persistent_id":"persistent-id","is_directory_restricted":true,"profile_photo_url":"https://example.com/member.jpg"}}],"warnings":[]}, - "team remove-member": {"input":{"email":"ada@example.com"},"results":[{"status":"removed","kind":"team_member","input":{"email":"ada@example.com"},"result":{"type":"team_member_remove","tag":"complete","async_job_id":"async-job-id"}}],"warnings":[]}, - "version": {"input":{},"results":[{"kind":"version","input":{},"result":{"version":"1.2.3","sdk_version":"sdk-version","spec_version":"spec-version"}}],"warnings":[]} + "account": {"ok":true,"schema_version":"1","command":"account","input":{"account_id":"dbid:lookup"},"results":[{"kind":"account","input":{"account_id":"dbid:lookup"},"result":{"type":"full","account_id":"dbid:account","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"email":"ada@example.com","email_verified":true,"disabled":false,"profile_photo_url":"https://example.com/profile.jpg","locale":"en","referral_link":"https://example.com/referral","is_paired":false,"account_type":"basic","is_teammate":true,"team_member_id":"dbmid:team-member","team":{"id":"team-id","name":"Engineering","member_id":"dbmid:team-member"}},"status":"found"}],"warnings":[]}, + "cp": {"ok":true,"schema_version":"1","command":"cp","input":{},"results":[{"input":{"from_path":"/Reports/old.pdf","to_path":"/Reports/copy.pdf"},"result":{"type":"file","path_display":"/Reports/copy.pdf","path_lower":"/reports/copy.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"copied","kind":"file"}],"warnings":[]}, + "du": {"ok":true,"schema_version":"1","command":"du","input":{},"results":[{"kind":"space_usage","input":{},"result":{"used":2048,"allocation":{"type":"team","allocated":1000000,"used":2048,"user_within_team_space_allocated":500000,"user_within_team_space_used_cached":1024,"user_within_team_space_limit_type":"fixed"}},"status":"reported"}],"warnings":[]}, + "get": {"ok":true,"schema_version":"1","command":"get","input":{"source":"/Reports/old.pdf","target":"old.pdf","recursive":false,"stdout":false},"results":[{"status":"downloaded","kind":"file","input":{"source":"/Reports/old.pdf","target":"old.pdf"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "ls": {"ok":true,"schema_version":"1","command":"ls","input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]}, + "mkdir": {"ok":true,"schema_version":"1","command":"mkdir","input":{"path":"/Reports/new","parents":true},"results":[{"status":"created","kind":"folder","input":{"path":"/Reports/new","parents":true},"result":{"type":"folder","path_display":"/Reports/new","path_lower":"/reports/new","id":"id:folder"}}],"warnings":[]}, + "mv": {"ok":true,"schema_version":"1","command":"mv","input":{},"results":[{"input":{"from_path":"/Reports/copy.pdf","to_path":"/Reports/moved.pdf"},"result":{"type":"file","path_display":"/Reports/moved.pdf","path_lower":"/reports/moved.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"moved","kind":"file"}],"warnings":[]}, + "put": {"ok":true,"schema_version":"1","command":"put","input":{"source":"README.md","target":"/README.md","recursive":true,"if_exists":"overwrite","stdin":false},"results":[{"status":"uploaded","kind":"file","input":{"source":"README.md","target":"/README.md"},"result":{"type":"file","path_display":"/README.md","path_lower":"/readme.md","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[{"code":"skipped_symlink","message":"skipped symlink","path":"docs/link"}]}, + "restore": {"ok":true,"schema_version":"1","command":"restore","input":{"path":"/Reports/old.pdf","revision":"015f"},"results":[{"status":"restored","kind":"file","input":{"path":"/Reports/old.pdf","revision":"015f"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "revs": {"ok":true,"schema_version":"1","command":"revs","input":{"path":"/Reports/old.pdf","long":true,"time":"server","time_format":"2006-01-02"},"results":[{"status":"revision","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]}, + "rm": {"ok":true,"schema_version":"1","command":"rm","input":{},"results":[{"input":{"path":"/Reports/old.pdf","permanent":false,"recursive":false,"force":false},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"deleted","kind":"file"}],"warnings":[]}, + "search": {"ok":true,"schema_version":"1","command":"search","input":{"query":"report","path":"/Reports","long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"},"input":{}}],"warnings":[]}, + "share list folder": {"ok":true,"schema_version":"1","command":"share list folder","input":{},"results":[{"status":"listed","kind":"shared_folder","result":{"type":"shared_folder","name":"Reports","path_lower":"/reports","shared_folder_id":"sfid:reports","preview_url":"https://www.dropbox.com/preview","access_type":"owner","is_inside_team_folder":false,"is_team_folder":true,"owner_display_names":["Ada Lovelace"],"parent_shared_folder_id":"sfid:parent","parent_folder_name":"Parent","time_invited":"2026-06-25T10:00:00Z","access_inheritance":"inherit"},"input":{}}],"warnings":[]}, + "share list link": {"ok":true,"schema_version":"1","command":"share list link","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[{"code":"deprecated_command","message":"use `dbxcli share-link list` instead"}]}, + "share-link create": {"ok":true,"schema_version":"1","command":"share-link create","input":{"path":"/Reports/old.pdf","access":"viewer","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, + "share-link download": {"ok":true,"schema_version":"1","command":"share-link download","input":{"url":"https://www.dropbox.com/s/example/old.pdf","target":"old.pdf","path":"/old.pdf","password":true},"results":[{"status":"downloaded","kind":"file","result":{"target":"old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]}, + "share-link info": {"ok":true,"schema_version":"1","command":"share-link info","input":{"url":"https://www.dropbox.com/s/example/old.pdf","path":"/old.pdf","password":true},"results":[{"status":"found","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, + "share-link list": {"ok":true,"schema_version":"1","command":"share-link list","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, + "share-link revoke": {"ok":true,"schema_version":"1","command":"share-link revoke","input":{"path":"/Reports/old.pdf"},"results":[{"status":"revoked","kind":"file","result":{"url":"https://www.dropbox.com/s/example/old.pdf","link":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}}},"input":{}}],"warnings":[]}, + "share-link update": {"ok":true,"schema_version":"1","command":"share-link update","input":{"url":"https://www.dropbox.com/s/example/old.pdf","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"updated","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]}, + "team add-member": {"ok":true,"schema_version":"1","command":"team add-member","input":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace"},"results":[{"status":"added","kind":"team_member","input":{"email":"ada@example.com","first_name":"Ada","last_name":"Lovelace"},"result":{"type":"team_member_add","tag":"complete","results":[{"tag":"success","email":"ada@example.com","member":{"type":"team_member","team_member_id":"dbmid:team-member","external_id":"external-member","account_id":"dbid:account","email":"ada@example.com","email_verified":true,"status":"active","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"role":"member_only","groups":["g:dev"],"member_folder_id":"ns:member-folder","membership_type":"full","invited_on":"2026-06-24T12:00:00Z","joined_on":"2026-06-25T12:00:00Z","suspended_on":"2026-06-26T12:00:00Z","persistent_id":"persistent-id","is_directory_restricted":true,"profile_photo_url":"https://example.com/member.jpg"}}]}}],"warnings":[]}, + "team info": {"ok":true,"schema_version":"1","command":"team info","input":{},"results":[{"status":"found","kind":"team","input":{},"result":{"type":"team","name":"Engineering","team_id":"team-id","num_licensed_users":10,"num_provisioned_users":8}}],"warnings":[]}, + "team list-groups": {"ok":true,"schema_version":"1","command":"team list-groups","input":{},"results":[{"status":"listed","kind":"team_group","result":{"type":"team_group","group_name":"Developers","group_id":"g:dev","group_external_id":"external-dev","member_count":3,"group_management_type":"company_managed"},"input":{}}],"warnings":[]}, + "team list-members": {"ok":true,"schema_version":"1","command":"team list-members","input":{},"results":[{"status":"listed","kind":"team_member","result":{"type":"team_member","team_member_id":"dbmid:team-member","external_id":"external-member","account_id":"dbid:account","email":"ada@example.com","email_verified":true,"status":"active","name":{"given_name":"Ada","surname":"Lovelace","familiar_name":"Ada","display_name":"Ada Lovelace","abbreviated_name":"AL"},"role":"member_only","groups":["g:dev"],"member_folder_id":"ns:member-folder","membership_type":"full","invited_on":"2026-06-24T12:00:00Z","joined_on":"2026-06-25T12:00:00Z","suspended_on":"2026-06-26T12:00:00Z","persistent_id":"persistent-id","is_directory_restricted":true,"profile_photo_url":"https://example.com/member.jpg"},"input":{}}],"warnings":[]}, + "team remove-member": {"ok":true,"schema_version":"1","command":"team remove-member","input":{"email":"ada@example.com"},"results":[{"status":"removed","kind":"team_member","input":{"email":"ada@example.com"},"result":{"type":"team_member_remove","tag":"complete","async_job_id":"async-job-id"}}],"warnings":[]}, + "version": {"ok":true,"schema_version":"1","command":"version","input":{},"results":[{"kind":"version","input":{},"result":{"version":"1.2.3","sdk_version":"sdk-version","spec_version":"spec-version"},"status":"reported"}],"warnings":[]} } diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index cba66fd..459b8a9 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -81,8 +81,11 @@ "path" ], "operation_output": [ + "command", "input", + "ok", "results", + "schema_version", "warnings" ], "operation_result": [ @@ -291,7 +294,9 @@ "input": "account_input", "result_input": "account_input", "result": "account", - "statuses": [], + "statuses": [ + "found" + ], "kinds": [ "account" ], @@ -303,8 +308,15 @@ "input": "empty", "result_input": "relocation_input", "result": "metadata", - "statuses": [], - "kinds": [], + "statuses": [ + "copied" + ], + "kinds": [ + "deleted", + "file", + "folder", + "unknown" + ], "warnings": [] }, "du": { @@ -313,7 +325,9 @@ "input": "empty", "result_input": "empty", "result": "du_output", - "statuses": [], + "statuses": [ + "reported" + ], "kinds": [ "space_usage" ], @@ -340,7 +354,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "ls_input", - "result_input": null, + "result_input": "empty", "result": "metadata", "statuses": [ "listed" @@ -374,8 +388,15 @@ "input": "empty", "result_input": "relocation_input", "result": "metadata", - "statuses": [], - "kinds": [], + "statuses": [ + "moved" + ], + "kinds": [ + "deleted", + "file", + "folder", + "unknown" + ], "warnings": [] }, "put": { @@ -416,7 +437,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "revs_input", - "result_input": null, + "result_input": "empty", "result": "metadata", "statuses": [ "revision" @@ -433,15 +454,23 @@ "input": "empty", "result_input": "remove_input", "result": "metadata", - "statuses": [], - "kinds": [], + "statuses": [ + "deleted", + "permanently_deleted" + ], + "kinds": [ + "deleted", + "file", + "folder", + "unknown" + ], "warnings": [] }, "search": { "top_level": "operation_output", "result_wrapper": "operation_result", "input": "search_input", - "result_input": null, + "result_input": "empty", "result": "metadata", "statuses": [ "found" @@ -458,7 +487,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "empty", - "result_input": null, + "result_input": "empty", "result": "share_folder", "statuses": [ "listed" @@ -472,7 +501,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_list_input", - "result_input": null, + "result_input": "empty", "result": "share_link_metadata", "statuses": [ "listed" @@ -490,7 +519,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_create_input", - "result_input": null, + "result_input": "empty", "result": "share_link_metadata", "statuses": [ "created", @@ -507,7 +536,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_download_input", - "result_input": null, + "result_input": "empty", "result": "share_link_download_result", "statuses": [ "downloaded" @@ -523,7 +552,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_info_input", - "result_input": null, + "result_input": "empty", "result": "share_link_metadata", "statuses": [ "found" @@ -539,7 +568,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_list_input", - "result_input": null, + "result_input": "empty", "result": "share_link_metadata", "statuses": [ "listed" @@ -555,7 +584,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_revoke_input", - "result_input": null, + "result_input": "empty", "result": "share_link_revoke_result", "statuses": [ "revoked" @@ -572,7 +601,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "share_link_update_input", - "result_input": null, + "result_input": "empty", "result": "share_link_metadata", "statuses": [ "updated" @@ -618,7 +647,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "empty", - "result_input": null, + "result_input": "empty", "result": "team_group", "statuses": [ "listed" @@ -632,7 +661,7 @@ "top_level": "operation_output", "result_wrapper": "operation_result", "input": "empty", - "result_input": null, + "result_input": "empty", "result": "team_member", "statuses": [ "listed" @@ -664,7 +693,9 @@ "input": "empty", "result_input": "empty", "result": "version", - "statuses": [], + "statuses": [ + "reported" + ], "kinds": [ "version" ], diff --git a/cmd/version.go b/cmd/version.go index 269950e..6c53c78 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -30,7 +30,10 @@ type versionOutput struct { SpecVersion string `json:"spec_version"` } -const versionKindVersion = "version" +const ( + versionJSONStatusReported = "reported" + versionKindVersion = "version" +) var dropboxVersionFunc = dropbox.Version @@ -52,7 +55,7 @@ func versionCommand(cmd *cobra.Command, version string) error { info := newVersionOutput(version) return commandOutput(cmd).Render(func(w io.Writer) error { return renderVersion(w, info) - }, newVersionOperationOutput(info)) + }, withJSONCommand(cmd, newVersionOperationOutput(info))) } func renderVersion(out io.Writer, info versionOutput) error { @@ -74,6 +77,6 @@ func newVersionOutput(version string) versionOutput { func newVersionOperationOutput(info versionOutput) jsonOperationOutput { input := versionInput{} return newJSONOperationOutput(input, []jsonOperationResult{ - newJSONOperationResult("", versionKindVersion, input, info), + newJSONOperationResult(versionJSONStatusReported, versionKindVersion, input, info), }, nil) } diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md new file mode 100644 index 0000000..66cc723 --- /dev/null +++ b/docs/json-schema/v1/README.md @@ -0,0 +1,30 @@ +# dbxcli JSON schema v1 + +These schemas describe the stable top-level JSON envelopes emitted by +`dbxcli --output=json`. + +- `success.schema.json` validates successful command responses. +- `error.schema.json` validates command error responses. + +Successful responses always include: + +- `ok: true` +- `schema_version: "1"` +- `command`: command path without the binary name, such as `ls` or + `share-link create` +- `input`: command-specific request fields +- `results`: command-specific result objects; every result includes `status`, + `kind`, `input`, and `result` +- `warnings`: machine-actionable warnings, or `[]` + +Error responses always include: + +- `ok: false` +- `schema_version: "1"` +- `command`: command path when available, or `dbxcli` for root/pre-parse errors +- `error.message`: human-readable error text +- `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/`. diff --git a/docs/json-schema/v1/error.schema.json b/docs/json-schema/v1/error.schema.json new file mode 100644 index 0000000..755bc98 --- /dev/null +++ b/docs/json-schema/v1/error.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dropbox/dbxcli/docs/json-schema/v1/error.schema.json", + "title": "dbxcli JSON error response", + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "schema_version", + "command", + "error", + "warnings" + ], + "properties": { + "ok": { + "const": false + }, + "schema_version": { + "const": "1" + }, + "command": { + "type": "string", + "description": "The dbxcli command path when available, or dbxcli for root/pre-parse errors." + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "message", + "code" + ], + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "invalid_arguments", + "path_conflict", + "auth_required", + "auth_refresh_failed", + "app_key_required", + "auth_exchange_failed", + "not_found", + "permission_denied", + "rate_limited", + "dropbox_api_error", + "structured_output_unsupported", + "unsupported_output_format", + "unknown_command", + "unknown_flag", + "command_failed" + ] + } + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/warning" + } + } + }, + "$defs": { + "warning": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + } +} diff --git a/docs/json-schema/v1/success.schema.json b/docs/json-schema/v1/success.schema.json new file mode 100644 index 0000000..00b9c97 --- /dev/null +++ b/docs/json-schema/v1/success.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dropbox/dbxcli/docs/json-schema/v1/success.schema.json", + "title": "dbxcli JSON success response", + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "schema_version", + "command", + "input", + "results", + "warnings" + ], + "properties": { + "ok": { + "const": true + }, + "schema_version": { + "const": "1" + }, + "command": { + "type": "string", + "description": "The dbxcli command path without the binary name, for example ls or share-link create." + }, + "input": { + "type": "object", + "description": "Command-specific request fields." + }, + "results": { + "type": "array", + "items": { + "$ref": "#/$defs/result" + } + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/warning" + } + } + }, + "$defs": { + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "kind", + "input", + "result" + ], + "properties": { + "status": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "input": { + "type": "object" + }, + "result": {} + } + }, + "warning": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + } +}