Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -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
Expand Down
22 changes: 19 additions & 3 deletions cmd/help_json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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] <file>",
Short: "Remove files or folders",
Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 12 additions & 1 deletion cmd/json_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ func expectedStructuredOutputCommands() []string {
"cp",
"du",
"get",
"logout",
"ls",
"mkdir",
"mv",
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -496,6 +501,7 @@ func expectedJSONErrorCodes() []string {
jsonErrorCodeAuthRequired,
jsonErrorCodeCommandFailed,
jsonErrorCodeDropboxAPIError,
jsonErrorCodeEnvTokenStillActive,
jsonErrorCodeInvalidArguments,
jsonErrorCodeNotFound,
jsonErrorCodePathConflict,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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](),
Expand Down Expand Up @@ -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}),
Expand Down Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions cmd/json_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type jsonWarning struct {
const (
jsonWarningCodeDeprecatedCommand = "deprecated_command"
jsonWarningCodeSkippedSymlink = "skipped_symlink"
jsonWarningCodeTokenRevokeFailed = "token_revoke_failed"
)

type jsonOperationOutput struct {
Expand Down
82 changes: 77 additions & 5 deletions cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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)
},
Expand All @@ -77,4 +148,5 @@ var logoutCmd = &cobra.Command{

func init() {
RootCmd.AddCommand(logoutCmd)
enableStructuredOutput(logoutCmd)
}
Loading
Loading