diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f062b9..7800d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ [Full Changelog](https://github.com/dropbox/dbxcli/compare/v3.5.0...HEAD) +**Added:** + +- Added structured `logout --output=json` output with saved-credential removal, token-revoke status, and already-logged-out reporting. + ## [v3.5.0](https://github.com/dropbox/dbxcli/tree/v3.5.0) (2026-06-26) [Full Changelog](https://github.com/dropbox/dbxcli/compare/v3.4.0...v3.5.0) diff --git a/README.md b/README.md index 0526f05..20c14d4 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ In JSON mode, error responses are written to stdout and the process exits with a } ``` -The full JSON command catalog, stable error codes, and schemas live in [docs/json-schema/v1](https://github.com/dropbox/dbxcli/tree/master/docs/json-schema/v1). Commands that intentionally do not support structured command-result JSON yet include `login`, `logout`, and `completion`, but their help is available as JSON with `--help --output=json`. Shell-completion protocol commands remain text-only. +The full JSON command catalog, stable error codes, and schemas live in [docs/json-schema/v1](https://github.com/dropbox/dbxcli/tree/master/docs/json-schema/v1). Commands that intentionally do not support structured command-result JSON yet include `login` and `completion`, but their help is available as JSON with `--help --output=json`. Shell-completion protocol commands remain text-only. ### Authentication @@ -222,8 +222,8 @@ Run `dbxcli login` to authorize dbxcli and save credentials: $ dbxcli login ``` -Commands require saved credentials. If no saved credentials are available, run -`dbxcli login` first or provide a token with `DBXCLI_ACCESS_TOKEN`. +Dropbox API commands require authentication. Run `dbxcli login` to save +credentials, or provide a short-lived token with `DBXCLI_ACCESS_TOKEN`. Personal and team logins use bundled Dropbox app keys by default. You can pass a custom app key as an option: @@ -244,6 +244,11 @@ Saved login credentials include a Dropbox refresh token and are refreshed automatically when the access token expires. If saved credentials are revoked or need to be replaced, run `dbxcli login` again. +Run `dbxcli logout` to revoke saved Dropbox access tokens and remove local saved +credentials. If `DBXCLI_ACCESS_TOKEN` is set, unset it before running logout; +environment-provided tokens are not saved locally and cannot be removed by +dbxcli. + Set `DBXCLI_AUTH_FILE` to use a different credentials file: ```sh diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index 578746a..99cda96 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -41,14 +41,14 @@ func TestJSONHelpSupportedForms(t *testing.T) { args: []string{"--help", "--output=json"}, wantCommand: "dbxcli", wantPath: "", - wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "ls", "rm", "team", "team add-member", "team info"}, + wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "logout", "ls", "rm", "team", "team add-member", "team info"}, }, { name: "root help output before", args: []string{"--output=json", "--help"}, wantCommand: "dbxcli", wantPath: "", - wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "ls", "rm", "team", "team add-member", "team info"}, + wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "logout", "ls", "rm", "team", "team add-member", "team info"}, }, { name: "command help output after", @@ -221,6 +221,14 @@ func TestJSONHelpManifestFields(t *testing.T) { t.Fatalf("login auth_modes = %v, want empty", login.AuthModes) } + logout := jsonHelpManifestByPath(t, got, "logout") + if !logout.SupportsStructuredOutput { + t.Fatal("logout supports_structured_output = false, want true") + } + if len(logout.AuthModes) != 0 { + t.Fatalf("logout auth_modes = %v, want empty", logout.AuthModes) + } + rm := jsonHelpManifestByPath(t, got, "rm") if rm.DestructiveLevel != destructiveLevelDelete { t.Fatalf("rm destructive_level = %q, want delete", rm.DestructiveLevel) @@ -502,6 +510,7 @@ func TestJSONHelpPreservesHelpCommandCompletions(t *testing.T) { } want := []cobra.Completion{ cobra.CompletionWithDesc("login", "Log in and save Dropbox credentials"), + cobra.CompletionWithDesc("logout", "Log out of the current session"), cobra.CompletionWithDesc("ls", "List files and folders"), } if !reflect.DeepEqual(completions, want) { @@ -577,6 +586,13 @@ func newJSONHelpTestRoot(t *testing.T) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return nil }, } + logout := &cobra.Command{ + Use: "logout", + Short: "Log out of the current session", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + enableStructuredOutput(logout) + rm := &cobra.Command{ Use: "rm [flags] ", Short: "Remove files or folders", @@ -611,7 +627,7 @@ func newJSONHelpTestRoot(t *testing.T) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { return nil }, } - root.AddCommand(ls, login, rm, team, hidden) + root.AddCommand(ls, login, logout, rm, team, hidden) installJSONHelp(root) return root } diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 30f74ad..18c9bde 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -228,6 +228,7 @@ func expectedStructuredOutputCommands() []string { "cp", "du", "get", + "logout", "ls", "mkdir", "mv", @@ -315,6 +316,10 @@ func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture { file: "ls_test.go", tests: []string{"TestLsJSONListsResultsAndInput", "TestLsJSONDeletedEntryIsStructured"}, }, + "logout": { + file: "logout_test.go", + tests: []string{"TestLogoutJSONReturnsLoggedOut", "TestLogoutJSONReturnsAlreadyLoggedOut", "TestLogoutJSONWarnsOnRemoteRevokeFailureAfterRemovingCredentials"}, + }, "mkdir": { file: "mkdir_test.go", tests: []string{"TestMkdirJSONOutputsCreatedFolder", "TestMkdirJSONParentsReturnsExistingFolderMetadata"}, @@ -496,6 +501,7 @@ func expectedJSONErrorCodes() []string { jsonErrorCodeAuthRequired, jsonErrorCodeCommandFailed, jsonErrorCodeDropboxAPIError, + jsonErrorCodeEnvTokenStillActive, jsonErrorCodeInvalidArguments, jsonErrorCodeNotFound, jsonErrorCodePathConflict, @@ -607,6 +613,9 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ newJSONOperationResult(lsJSONStatusListed, file.Type, nil, file), }, nil), + "logout": newJSONOperationOutput(nil, []jsonOperationResult{ + newJSONOperationResult(logoutStatusLoggedOut, logoutKindAuth, nil, logoutResult{RemovedSavedCredentials: true, RemoteTokenRevoked: true}), + }, nil), "mkdir": newJSONOperationOutput(mkdirInput{Path: "/Reports/new", Parents: true}, []jsonOperationResult{ newJSONOperationResult(mkdirStatusCreated, mkdirKindFolder, mkdirInput{Path: "/Reports/new", Parents: true}, sampleJSONFolderMetadata("/Reports/new")), }, nil), @@ -821,6 +830,7 @@ func jsonContractDefinitions() map[string][]string { "get_result_input": jsonFieldNames[getResultInput](), "help_input": jsonFieldNames[jsonHelpInput](), "ls_input": jsonFieldNames[lsInput](), + "logout_result": jsonFieldNames[logoutResult](), "metadata": jsonFieldNames[jsonMetadata](), "mkdir_input": jsonFieldNames[mkdirInput](), "operation_output": jsonFieldNames[jsonOperationOutput](), @@ -862,6 +872,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil), "help": operationSchema("help_input", schemaRef("empty"), "command_manifest", []string{jsonHelpStatusDescribed}, []string{jsonHelpKindCommand}, nil), "ls": operationSchema("ls_input", schemaRef("empty"), "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil), + "logout": operationSchema("empty", schemaRef("empty"), "logout_result", []string{logoutStatusAlreadyLoggedOut, logoutStatusLoggedOut}, []string{logoutKindAuth}, []string{jsonWarningCodeTokenRevokeFailed}), "mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting}, []string{mkdirKindFolder}, 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}), @@ -1099,7 +1110,7 @@ func TestJSONOperationOutputContractShape(t *testing.T) { } func TestUnsupportedCommandsReturnJSONErrorEnvelope(t *testing.T) { - for _, name := range []string{"login", "logout", "completion"} { + for _, name := range []string{"login", "completion"} { t.Run(name, func(t *testing.T) { var stdout, stderr bytes.Buffer cmd := &cobra.Command{Use: name} diff --git a/cmd/json_output.go b/cmd/json_output.go index 5f9210c..11a47ae 100644 --- a/cmd/json_output.go +++ b/cmd/json_output.go @@ -29,6 +29,7 @@ type jsonWarning struct { const ( jsonWarningCodeDeprecatedCommand = "deprecated_command" jsonWarningCodeSkippedSymlink = "skipped_symlink" + jsonWarningCodeTokenRevokeFailed = "token_revoke_failed" ) type jsonOperationOutput struct { diff --git a/cmd/logout.go b/cmd/logout.go index 3ed0471..78de36a 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -15,13 +15,27 @@ package cmd import ( + "errors" "os" + "sort" + "github.com/dropbox/dbxcli/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth" "github.com/spf13/cobra" ) +const ( + logoutStatusLoggedOut = "logged_out" + logoutStatusAlreadyLoggedOut = "already_logged_out" + logoutKindAuth = "auth" +) + +type logoutResult struct { + RemovedSavedCredentials bool `json:"removed_saved_credentials"` + RemoteTokenRevoked bool `json:"remote_token_revoked"` +} + var revokeAccessToken = func(domain string, token string) error { cfg := dropbox.Config{ Token: token, @@ -39,7 +53,9 @@ var revokeAccessToken = func(domain string, token string) error { // Command logout revokes all saved API tokens and deletes auth.json. func logout(cmd *cobra.Command, args []string) error { - out := commandOutput(cmd) + if os.Getenv(envAccessToken) != "" { + return newCodedError(jsonErrorCodeEnvTokenStillActive, errors.New("DBXCLI_ACCESS_TOKEN is set; unset it before running logout.")) + } filePath, err := authFilePath() if err != nil { @@ -48,27 +64,82 @@ func logout(cmd *cobra.Command, args []string) error { tokMap, err := readTokens(filePath) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return renderLogoutResult(cmd, logoutStatusAlreadyLoggedOut, false, false, nil) + } return err } - for domain, tokens := range tokMap { - for _, token := range tokens { + tokenCount := 0 + revokeFailed := false + for _, domain := range sortedTokenDomains(tokMap) { + tokens := tokMap[domain] + for _, tokenType := range sortedTokenTypes(tokens) { + token := tokens[tokenType] if token.AccessToken == "" { continue } + tokenCount++ if err = revokeAccessToken(domain, token.AccessToken); err != nil { - out.Warn("could not revoke token (may be expired): %v", err) + revokeFailed = true + if commandOutputFormat(cmd) == output.FormatText { + commandOutput(cmd).Warn("could not revoke token (may be expired): %v", err) + } } } } - return os.Remove(filePath) + if err := os.Remove(filePath); err != nil { + return err + } + + warnings := []jsonWarning(nil) + if revokeFailed { + warnings = append(warnings, jsonWarning{ + Code: jsonWarningCodeTokenRevokeFailed, + Message: "Saved credentials were removed locally, but one or more Dropbox tokens could not be revoked remotely.", + }) + } + return renderLogoutResult(cmd, logoutStatusLoggedOut, true, tokenCount > 0 && !revokeFailed, warnings) +} + +func renderLogoutResult(cmd *cobra.Command, status string, removedSavedCredentials bool, remoteTokenRevoked bool, warnings []jsonWarning) error { + return renderJSONOperationOutputWithWarnings(cmd, nil, []jsonOperationResult{ + newJSONOperationResult(status, logoutKindAuth, nil, logoutResult{ + RemovedSavedCredentials: removedSavedCredentials, + RemoteTokenRevoked: remoteTokenRevoked, + }), + }, warnings) +} + +func sortedTokenDomains(tokMap TokenMap) []string { + domains := make([]string, 0, len(tokMap)) + for domain := range tokMap { + domains = append(domains, domain) + } + sort.Strings(domains) + return domains +} + +func sortedTokenTypes(tokens map[string]storedCredential) []string { + tokenTypes := make([]string, 0, len(tokens)) + for tokenType := range tokens { + tokenTypes = append(tokenTypes, tokenType) + } + sort.Strings(tokenTypes) + return tokenTypes } // logoutCmd represents the logout command var logoutCmd = &cobra.Command{ Use: "logout [flags]", Short: "Log out of the current session", + Long: `Log out of the current session. + +Logout revokes saved Dropbox access tokens by default and removes local saved +credentials. If DBXCLI_ACCESS_TOKEN is set, unset it before running logout; +environment-provided tokens are not saved locally and cannot be removed by +dbxcli.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return validateOutputFormat(cmd) }, @@ -77,4 +148,5 @@ var logoutCmd = &cobra.Command{ func init() { RootCmd.AddCommand(logoutCmd) + enableStructuredOutput(logoutCmd) } diff --git a/cmd/logout_test.go b/cmd/logout_test.go index 1b3f0f3..b5c2d4b 100644 --- a/cmd/logout_test.go +++ b/cmd/logout_test.go @@ -1,14 +1,33 @@ package cmd import ( + "bytes" + "encoding/json" "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "github.com/spf13/cobra" ) +type logoutOutputForTest struct { + OK bool `json:"ok"` + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Input struct { + } `json:"input"` + Results []struct { + Status string `json:"status"` + Kind string `json:"kind"` + Input struct{} `json:"input"` + Result logoutResult `json:"result"` + } `json:"results"` + Warnings []jsonWarning `json:"warnings"` +} + func TestLogoutRevokesLegacyAndRefreshableCredentials(t *testing.T) { authFile := filepath.Join(t.TempDir(), "auth.json") if err := os.WriteFile(authFile, []byte(`{"":{"personal":"legacy-token","teamManage":{"access_token":"refreshable-token","refresh_token":"refresh-token","app_key":"app-key"}}}`), 0600); err != nil { @@ -41,3 +60,243 @@ func TestLogoutRevokesLegacyAndRefreshableCredentials(t *testing.T) { t.Fatalf("expected auth file to be removed, got %v", err) } } + +func TestLogoutJSONReturnsLoggedOut(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(authFile, []byte(`{"":{"personal":"legacy-token","teamManage":{"access_token":"refreshable-token","refresh_token":"refresh-token","app_key":"app-key"}}}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv(envAuthFile, authFile) + + restoreRevokeAccessToken := stubRevokeAccessToken(t, func(domain string, token string) error { + return nil + }) + defer restoreRevokeAccessToken() + + cmd, stdout, stderr := logoutTestCommand("json") + if err := logout(cmd, nil); err != nil { + t.Fatal(err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + got := decodeLogoutOutput(t, stdout.String()) + assertLogoutResult(t, got, logoutStatusLoggedOut, true, true) + if len(got.Warnings) != 0 { + t.Fatalf("warnings = %+v, want empty", got.Warnings) + } + if _, err := os.Stat(authFile); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected auth file to be removed, got %v", err) + } +} + +func TestLogoutJSONReturnsAlreadyLoggedOut(t *testing.T) { + t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) + + restoreRevokeAccessToken := stubRevokeAccessToken(t, func(domain string, token string) error { + t.Fatalf("revokeAccessToken(%q, %q) should not be called", domain, token) + return nil + }) + defer restoreRevokeAccessToken() + + cmd, stdout, stderr := logoutTestCommand("json") + if err := logout(cmd, nil); err != nil { + t.Fatal(err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + got := decodeLogoutOutput(t, stdout.String()) + assertLogoutResult(t, got, logoutStatusAlreadyLoggedOut, false, false) + if len(got.Warnings) != 0 { + t.Fatalf("warnings = %+v, want empty", got.Warnings) + } +} + +func TestLogoutJSONWarnsOnRemoteRevokeFailureAfterRemovingCredentials(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(authFile, []byte(`{"":{"personal":"legacy-token"}}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv(envAuthFile, authFile) + + restoreRevokeAccessToken := stubRevokeAccessToken(t, func(domain string, token string) error { + return fmt.Errorf("revoke failed") + }) + defer restoreRevokeAccessToken() + + cmd, stdout, stderr := logoutTestCommand("json") + if err := logout(cmd, nil); err != nil { + t.Fatal(err) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + got := decodeLogoutOutput(t, stdout.String()) + assertLogoutResult(t, got, logoutStatusLoggedOut, true, false) + if len(got.Warnings) != 1 { + t.Fatalf("warnings = %+v, want one warning", got.Warnings) + } + if got.Warnings[0].Code != jsonWarningCodeTokenRevokeFailed { + t.Fatalf("warning code = %q, want %q", got.Warnings[0].Code, jsonWarningCodeTokenRevokeFailed) + } + if _, err := os.Stat(authFile); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected auth file to be removed, got %v", err) + } +} + +func TestLogoutInvalidAuthFileReturnsErrorAndLeavesCredentials(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "auth.json") + content := []byte(`not-json`) + if err := os.WriteFile(authFile, content, 0600); err != nil { + t.Fatal(err) + } + t.Setenv(envAuthFile, authFile) + + restoreRevokeAccessToken := stubRevokeAccessToken(t, func(domain string, token string) error { + t.Fatalf("revokeAccessToken(%q, %q) should not be called", domain, token) + return nil + }) + defer restoreRevokeAccessToken() + + cmd, stdout, stderr := logoutTestCommand("json") + if err := logout(cmd, nil); err == nil { + t.Fatal("expected invalid auth file error") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + got, err := os.ReadFile(authFile) + if err != nil { + t.Fatalf("read auth file: %v", err) + } + if string(got) != string(content) { + t.Fatalf("auth file = %q, want %q", got, content) + } +} + +func TestLogoutEnvTokenStillActiveReturnsJSONErrorAndLeavesCredentials(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(authFile, []byte(`{"":{"personal":"legacy-token"}}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv(envAuthFile, authFile) + t.Setenv(envAccessToken, "env-token") + + cmd, stdout, stderr := logoutTestCommand("json") + err := logout(cmd, nil) + if err == nil { + t.Fatal("expected env token error") + } + if got, want := jsonErrorCode(err), jsonErrorCodeEnvTokenStillActive; got != want { + t.Fatalf("jsonErrorCode = %q, want %q", got, want) + } + renderCommandError(cmd, err) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + + got := decodeJSONErrorResponse(t, stdout.String()) + if got.Error.Code != jsonErrorCodeEnvTokenStillActive { + t.Fatalf("code = %q, want %q", got.Error.Code, jsonErrorCodeEnvTokenStillActive) + } + if _, err := os.Stat(authFile); err != nil { + t.Fatalf("expected auth file to remain, got %v", err) + } +} + +func TestLogoutTextEnvTokenStillActiveReturnsErrorAndLeavesCredentials(t *testing.T) { + authFile := filepath.Join(t.TempDir(), "auth.json") + if err := os.WriteFile(authFile, []byte(`{"":{"personal":"legacy-token"}}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv(envAuthFile, authFile) + t.Setenv(envAccessToken, "env-token") + + cmd, stdout, stderr := logoutTestCommand("text") + err := logout(cmd, nil) + if err == nil { + t.Fatal("expected env token error") + } + renderCommandError(cmd, err) + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), envAccessToken) { + t.Fatalf("stderr = %q, want %s", stderr.String(), envAccessToken) + } + if _, err := os.Stat(authFile); err != nil { + t.Fatalf("expected auth file to remain, got %v", err) + } +} + +func TestLogoutSupportsStructuredOutput(t *testing.T) { + if !commandSupportsStructuredOutput(logoutCmd) { + t.Fatal("logout command should support structured output") + } +} + +func logoutTestCommand(format string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + var stdout, stderr bytes.Buffer + cmd := &cobra.Command{Use: "logout"} + cmd.Flags().String(outputFlag, format, "") + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + return cmd, &stdout, &stderr +} + +func decodeLogoutOutput(t *testing.T, value string) logoutOutputForTest { + t.Helper() + + var got logoutOutputForTest + if err := json.Unmarshal([]byte(value), &got); err != nil { + t.Fatalf("decode logout JSON output %q: %v", value, err) + } + return got +} + +func assertLogoutResult(t *testing.T, got logoutOutputForTest, status string, removedSavedCredentials bool, remoteTokenRevoked bool) { + t.Helper() + + if !got.OK { + t.Fatal("ok = false, want true") + } + if got.SchemaVersion != jsonSchemaVersion { + t.Fatalf("schema_version = %q, want %q", got.SchemaVersion, jsonSchemaVersion) + } + if got.Command != "logout" { + t.Fatalf("command = %q, want logout", got.Command) + } + if len(got.Results) != 1 { + t.Fatalf("results = %+v, want one result", got.Results) + } + result := got.Results[0] + if result.Status != status { + t.Fatalf("status = %q, want %q", result.Status, status) + } + if result.Kind != logoutKindAuth { + t.Fatalf("kind = %q, want %q", result.Kind, logoutKindAuth) + } + if result.Result.RemovedSavedCredentials != removedSavedCredentials { + t.Fatalf("removed_saved_credentials = %v, want %v", result.Result.RemovedSavedCredentials, removedSavedCredentials) + } + if result.Result.RemoteTokenRevoked != remoteTokenRevoked { + t.Fatalf("remote_token_revoked = %v, want %v", result.Result.RemoteTokenRevoked, remoteTokenRevoked) + } +} + +func stubRevokeAccessToken(t *testing.T, fn func(domain string, token string) error) func() { + t.Helper() + + orig := revokeAccessToken + revokeAccessToken = fn + return func() { + revokeAccessToken = orig + } +} diff --git a/cmd/output.go b/cmd/output.go index 08700d3..284c629 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -22,6 +22,7 @@ const ( jsonErrorCodeAuthRefreshFailed = "auth_refresh_failed" jsonErrorCodeAuthRequired = "auth_required" jsonErrorCodeDropboxAPIError = "dropbox_api_error" + jsonErrorCodeEnvTokenStillActive = "env_token_still_active" jsonErrorCodeInvalidArguments = "invalid_arguments" jsonErrorCodeNotFound = "not_found" jsonErrorCodePathConflict = "path_conflict" diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index a1ac0a3..f6a7d2b 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -5,6 +5,7 @@ "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":[]}, "help": {"ok":true,"schema_version":"1","command":"help","input":{"help":true,"path":"ls"},"results":[{"status":"described","kind":"command","input":{},"result":{"path":"ls","use":"dbxcli ls [flags] []","short":"List files and folders","aliases":[],"runnable":true,"flags":[{"name":"output","type":"string","default":"text","usage":"Output format: text, json","inherited":true}],"supports_structured_output":true,"auth_modes":["personal","team-access"],"destructive_level":"none"}}],"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":[]}, + "logout": {"ok":true,"schema_version":"1","command":"logout","input":{},"results":[{"status":"logged_out","kind":"auth","input":{},"result":{"removed_saved_credentials":true,"remote_token_revoked":true}}],"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"}]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 7a5d434..5970559 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -76,6 +76,10 @@ "help", "path" ], + "logout_result": [ + "remote_token_revoked", + "removed_saved_credentials" + ], "ls_input": [ "include_deleted", "long", @@ -401,6 +405,23 @@ ], "warnings": [] }, + "logout": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "logout_result", + "statuses": [ + "already_logged_out", + "logged_out" + ], + "kinds": [ + "auth" + ], + "warnings": [ + "token_revoke_failed" + ] + }, "mkdir": { "top_level": "operation_output", "result_wrapper": "operation_result", diff --git a/docs/commands/dbxcli_logout.md b/docs/commands/dbxcli_logout.md index ec10a28..e4d7f25 100644 --- a/docs/commands/dbxcli_logout.md +++ b/docs/commands/dbxcli_logout.md @@ -4,6 +4,15 @@ Log out of the current session +### Synopsis + +Log out of the current session. + +Logout revokes saved Dropbox access tokens by default and removes local saved +credentials. If DBXCLI_ACCESS_TOKEN is set, unset it before running logout; +environment-provided tokens are not saved locally and cannot be removed by +dbxcli. + ``` dbxcli logout [flags] ``` diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index a152a5f..3b87736 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -43,7 +43,7 @@ In JSON mode, error responses are written to stdout and the process exits with a non-zero status. Commands that intentionally do not support structured command-result JSON yet -include `login`, `logout`, and `completion`. Their help output is still +include `login` and `completion`. Their help output is still available as a JSON command manifest with `--help --output=json`; for example, `dbxcli --help --output=json`, `dbxcli version --help --output=json`, and `dbxcli --output=json help version`. `dbxcli --output=json` without `--help` @@ -55,12 +55,15 @@ Current JSON-enabled command paths include `version`, `account`, `du`, `ls`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `share list folder`, `share list link`, `team info`, `team list-members`, `team list-groups`, -`team add-member`, `team remove-member`, `mkdir`, `rm`, and `restore`. +`team add-member`, `team remove-member`, `mkdir`, `rm`, `restore`, and +`logout`. Warnings are objects with a stable `code` and human-readable `message`; they may include optional command-specific details. Current warning codes include `deprecated_command` for deprecated command paths and `skipped_symlink` for -symlinks skipped by recursive upload. +symlinks skipped by recursive upload. `logout` may return +`token_revoke_failed` when saved credentials were removed locally but one or +more Dropbox tokens could not be revoked remotely. Stable error codes: @@ -76,6 +79,7 @@ Stable error codes: | `permission_denied` | Dropbox denied access because of permissions, scope, member selection, or state. | | `rate_limited` | Dropbox rate limited the request. | | `dropbox_api_error` | Dropbox returned an API error that does not map to a more specific code yet. | +| `env_token_still_active` | `DBXCLI_ACCESS_TOKEN` is set and must be unset before logout can complete. | | `structured_output_unsupported` | The command does not support `--output=json` yet. | | `unknown_command` | Cobra could not resolve the command. | | `unknown_flag` | Cobra could not resolve a flag. | diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 7a5d434..5970559 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -76,6 +76,10 @@ "help", "path" ], + "logout_result": [ + "remote_token_revoked", + "removed_saved_credentials" + ], "ls_input": [ "include_deleted", "long", @@ -401,6 +405,23 @@ ], "warnings": [] }, + "logout": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "empty", + "result_input": "empty", + "result": "logout_result", + "statuses": [ + "already_logged_out", + "logged_out" + ], + "kinds": [ + "auth" + ], + "warnings": [ + "token_revoke_failed" + ] + }, "mkdir": { "top_level": "operation_output", "result_wrapper": "operation_result", diff --git a/docs/json-schema/v1/error.schema.json b/docs/json-schema/v1/error.schema.json index dd9209a..6b4c694 100644 --- a/docs/json-schema/v1/error.schema.json +++ b/docs/json-schema/v1/error.schema.json @@ -46,6 +46,7 @@ "permission_denied", "rate_limited", "dropbox_api_error", + "env_token_still_active", "structured_output_unsupported", "unknown_command", "unknown_flag",