Skip to content

Commit 19e525a

Browse files
Merge pull request #297 from dropbox/json/logout-structured-output
Add structured JSON output to logout command
2 parents e59d0c8 + 2eff6dc commit 19e525a

14 files changed

Lines changed: 441 additions & 15 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
[Full Changelog](https://github.com/dropbox/dbxcli/compare/v3.5.0...HEAD)
66

7+
**Added:**
8+
9+
- Added structured `logout --output=json` output with saved-credential removal, token-revoke status, and already-logged-out reporting.
10+
711
## [v3.5.0](https://github.com/dropbox/dbxcli/tree/v3.5.0) (2026-06-26)
812
[Full Changelog](https://github.com/dropbox/dbxcli/compare/v3.4.0...v3.5.0)
913

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ In JSON mode, error responses are written to stdout and the process exits with a
211211
}
212212
```
213213
214-
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.
214+
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.
215215
216216
### Authentication
217217
@@ -222,8 +222,8 @@ Run `dbxcli login` to authorize dbxcli and save credentials:
222222
$ dbxcli login
223223
```
224224
225-
Commands require saved credentials. If no saved credentials are available, run
226-
`dbxcli login` first or provide a token with `DBXCLI_ACCESS_TOKEN`.
225+
Dropbox API commands require authentication. Run `dbxcli login` to save
226+
credentials, or provide a short-lived token with `DBXCLI_ACCESS_TOKEN`.
227227
228228
Personal and team logins use bundled Dropbox app keys by default. You can pass
229229
a custom app key as an option:
@@ -244,6 +244,11 @@ Saved login credentials include a Dropbox refresh token and are refreshed
244244
automatically when the access token expires. If saved credentials are revoked or
245245
need to be replaced, run `dbxcli login` again.
246246
247+
Run `dbxcli logout` to revoke saved Dropbox access tokens and remove local saved
248+
credentials. If `DBXCLI_ACCESS_TOKEN` is set, unset it before running logout;
249+
environment-provided tokens are not saved locally and cannot be removed by
250+
dbxcli.
251+
247252
Set `DBXCLI_AUTH_FILE` to use a different credentials file:
248253
249254
```sh

cmd/help_json_test.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ func TestJSONHelpSupportedForms(t *testing.T) {
4141
args: []string{"--help", "--output=json"},
4242
wantCommand: "dbxcli",
4343
wantPath: "",
44-
wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "ls", "rm", "team", "team add-member", "team info"},
44+
wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "logout", "ls", "rm", "team", "team add-member", "team info"},
4545
},
4646
{
4747
name: "root help output before",
4848
args: []string{"--output=json", "--help"},
4949
wantCommand: "dbxcli",
5050
wantPath: "",
51-
wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "ls", "rm", "team", "team add-member", "team info"},
51+
wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "logout", "ls", "rm", "team", "team add-member", "team info"},
5252
},
5353
{
5454
name: "command help output after",
@@ -221,6 +221,14 @@ func TestJSONHelpManifestFields(t *testing.T) {
221221
t.Fatalf("login auth_modes = %v, want empty", login.AuthModes)
222222
}
223223

224+
logout := jsonHelpManifestByPath(t, got, "logout")
225+
if !logout.SupportsStructuredOutput {
226+
t.Fatal("logout supports_structured_output = false, want true")
227+
}
228+
if len(logout.AuthModes) != 0 {
229+
t.Fatalf("logout auth_modes = %v, want empty", logout.AuthModes)
230+
}
231+
224232
rm := jsonHelpManifestByPath(t, got, "rm")
225233
if rm.DestructiveLevel != destructiveLevelDelete {
226234
t.Fatalf("rm destructive_level = %q, want delete", rm.DestructiveLevel)
@@ -502,6 +510,7 @@ func TestJSONHelpPreservesHelpCommandCompletions(t *testing.T) {
502510
}
503511
want := []cobra.Completion{
504512
cobra.CompletionWithDesc("login", "Log in and save Dropbox credentials"),
513+
cobra.CompletionWithDesc("logout", "Log out of the current session"),
505514
cobra.CompletionWithDesc("ls", "List files and folders"),
506515
}
507516
if !reflect.DeepEqual(completions, want) {
@@ -577,6 +586,13 @@ func newJSONHelpTestRoot(t *testing.T) *cobra.Command {
577586
RunE: func(cmd *cobra.Command, args []string) error { return nil },
578587
}
579588

589+
logout := &cobra.Command{
590+
Use: "logout",
591+
Short: "Log out of the current session",
592+
RunE: func(cmd *cobra.Command, args []string) error { return nil },
593+
}
594+
enableStructuredOutput(logout)
595+
580596
rm := &cobra.Command{
581597
Use: "rm [flags] <file>",
582598
Short: "Remove files or folders",
@@ -611,7 +627,7 @@ func newJSONHelpTestRoot(t *testing.T) *cobra.Command {
611627
RunE: func(cmd *cobra.Command, args []string) error { return nil },
612628
}
613629

614-
root.AddCommand(ls, login, rm, team, hidden)
630+
root.AddCommand(ls, login, logout, rm, team, hidden)
615631
installJSONHelp(root)
616632
return root
617633
}

cmd/json_contract_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ func expectedStructuredOutputCommands() []string {
228228
"cp",
229229
"du",
230230
"get",
231+
"logout",
231232
"ls",
232233
"mkdir",
233234
"mv",
@@ -315,6 +316,10 @@ func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture {
315316
file: "ls_test.go",
316317
tests: []string{"TestLsJSONListsResultsAndInput", "TestLsJSONDeletedEntryIsStructured"},
317318
},
319+
"logout": {
320+
file: "logout_test.go",
321+
tests: []string{"TestLogoutJSONReturnsLoggedOut", "TestLogoutJSONReturnsAlreadyLoggedOut", "TestLogoutJSONWarnsOnRemoteRevokeFailureAfterRemovingCredentials"},
322+
},
318323
"mkdir": {
319324
file: "mkdir_test.go",
320325
tests: []string{"TestMkdirJSONOutputsCreatedFolder", "TestMkdirJSONParentsReturnsExistingFolderMetadata"},
@@ -496,6 +501,7 @@ func expectedJSONErrorCodes() []string {
496501
jsonErrorCodeAuthRequired,
497502
jsonErrorCodeCommandFailed,
498503
jsonErrorCodeDropboxAPIError,
504+
jsonErrorCodeEnvTokenStillActive,
499505
jsonErrorCodeInvalidArguments,
500506
jsonErrorCodeNotFound,
501507
jsonErrorCodePathConflict,
@@ -607,6 +613,9 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput {
607613
"ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{
608614
newJSONOperationResult(lsJSONStatusListed, file.Type, nil, file),
609615
}, nil),
616+
"logout": newJSONOperationOutput(nil, []jsonOperationResult{
617+
newJSONOperationResult(logoutStatusLoggedOut, logoutKindAuth, nil, logoutResult{RemovedSavedCredentials: true, RemoteTokenRevoked: true}),
618+
}, nil),
610619
"mkdir": newJSONOperationOutput(mkdirInput{Path: "/Reports/new", Parents: true}, []jsonOperationResult{
611620
newJSONOperationResult(mkdirStatusCreated, mkdirKindFolder, mkdirInput{Path: "/Reports/new", Parents: true}, sampleJSONFolderMetadata("/Reports/new")),
612621
}, nil),
@@ -821,6 +830,7 @@ func jsonContractDefinitions() map[string][]string {
821830
"get_result_input": jsonFieldNames[getResultInput](),
822831
"help_input": jsonFieldNames[jsonHelpInput](),
823832
"ls_input": jsonFieldNames[lsInput](),
833+
"logout_result": jsonFieldNames[logoutResult](),
824834
"metadata": jsonFieldNames[jsonMetadata](),
825835
"mkdir_input": jsonFieldNames[mkdirInput](),
826836
"operation_output": jsonFieldNames[jsonOperationOutput](),
@@ -862,6 +872,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema {
862872
"get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil),
863873
"help": operationSchema("help_input", schemaRef("empty"), "command_manifest", []string{jsonHelpStatusDescribed}, []string{jsonHelpKindCommand}, nil),
864874
"ls": operationSchema("ls_input", schemaRef("empty"), "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil),
875+
"logout": operationSchema("empty", schemaRef("empty"), "logout_result", []string{logoutStatusAlreadyLoggedOut, logoutStatusLoggedOut}, []string{logoutKindAuth}, []string{jsonWarningCodeTokenRevokeFailed}),
865876
"mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting}, []string{mkdirKindFolder}, nil),
866877
"mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved}, metadataKinds(), nil),
867878
"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) {
10991110
}
11001111

11011112
func TestUnsupportedCommandsReturnJSONErrorEnvelope(t *testing.T) {
1102-
for _, name := range []string{"login", "logout", "completion"} {
1113+
for _, name := range []string{"login", "completion"} {
11031114
t.Run(name, func(t *testing.T) {
11041115
var stdout, stderr bytes.Buffer
11051116
cmd := &cobra.Command{Use: name}

cmd/json_output.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type jsonWarning struct {
2929
const (
3030
jsonWarningCodeDeprecatedCommand = "deprecated_command"
3131
jsonWarningCodeSkippedSymlink = "skipped_symlink"
32+
jsonWarningCodeTokenRevokeFailed = "token_revoke_failed"
3233
)
3334

3435
type jsonOperationOutput struct {

cmd/logout.go

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,27 @@
1515
package cmd
1616

1717
import (
18+
"errors"
1819
"os"
20+
"sort"
1921

22+
"github.com/dropbox/dbxcli/internal/output"
2023
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
2124
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/auth"
2225
"github.com/spf13/cobra"
2326
)
2427

28+
const (
29+
logoutStatusLoggedOut = "logged_out"
30+
logoutStatusAlreadyLoggedOut = "already_logged_out"
31+
logoutKindAuth = "auth"
32+
)
33+
34+
type logoutResult struct {
35+
RemovedSavedCredentials bool `json:"removed_saved_credentials"`
36+
RemoteTokenRevoked bool `json:"remote_token_revoked"`
37+
}
38+
2539
var revokeAccessToken = func(domain string, token string) error {
2640
cfg := dropbox.Config{
2741
Token: token,
@@ -39,7 +53,9 @@ var revokeAccessToken = func(domain string, token string) error {
3953

4054
// Command logout revokes all saved API tokens and deletes auth.json.
4155
func logout(cmd *cobra.Command, args []string) error {
42-
out := commandOutput(cmd)
56+
if os.Getenv(envAccessToken) != "" {
57+
return newCodedError(jsonErrorCodeEnvTokenStillActive, errors.New("DBXCLI_ACCESS_TOKEN is set; unset it before running logout."))
58+
}
4359

4460
filePath, err := authFilePath()
4561
if err != nil {
@@ -48,27 +64,82 @@ func logout(cmd *cobra.Command, args []string) error {
4864

4965
tokMap, err := readTokens(filePath)
5066
if err != nil {
67+
if errors.Is(err, os.ErrNotExist) {
68+
return renderLogoutResult(cmd, logoutStatusAlreadyLoggedOut, false, false, nil)
69+
}
5170
return err
5271
}
5372

54-
for domain, tokens := range tokMap {
55-
for _, token := range tokens {
73+
tokenCount := 0
74+
revokeFailed := false
75+
for _, domain := range sortedTokenDomains(tokMap) {
76+
tokens := tokMap[domain]
77+
for _, tokenType := range sortedTokenTypes(tokens) {
78+
token := tokens[tokenType]
5679
if token.AccessToken == "" {
5780
continue
5881
}
82+
tokenCount++
5983
if err = revokeAccessToken(domain, token.AccessToken); err != nil {
60-
out.Warn("could not revoke token (may be expired): %v", err)
84+
revokeFailed = true
85+
if commandOutputFormat(cmd) == output.FormatText {
86+
commandOutput(cmd).Warn("could not revoke token (may be expired): %v", err)
87+
}
6188
}
6289
}
6390
}
6491

65-
return os.Remove(filePath)
92+
if err := os.Remove(filePath); err != nil {
93+
return err
94+
}
95+
96+
warnings := []jsonWarning(nil)
97+
if revokeFailed {
98+
warnings = append(warnings, jsonWarning{
99+
Code: jsonWarningCodeTokenRevokeFailed,
100+
Message: "Saved credentials were removed locally, but one or more Dropbox tokens could not be revoked remotely.",
101+
})
102+
}
103+
return renderLogoutResult(cmd, logoutStatusLoggedOut, true, tokenCount > 0 && !revokeFailed, warnings)
104+
}
105+
106+
func renderLogoutResult(cmd *cobra.Command, status string, removedSavedCredentials bool, remoteTokenRevoked bool, warnings []jsonWarning) error {
107+
return renderJSONOperationOutputWithWarnings(cmd, nil, []jsonOperationResult{
108+
newJSONOperationResult(status, logoutKindAuth, nil, logoutResult{
109+
RemovedSavedCredentials: removedSavedCredentials,
110+
RemoteTokenRevoked: remoteTokenRevoked,
111+
}),
112+
}, warnings)
113+
}
114+
115+
func sortedTokenDomains(tokMap TokenMap) []string {
116+
domains := make([]string, 0, len(tokMap))
117+
for domain := range tokMap {
118+
domains = append(domains, domain)
119+
}
120+
sort.Strings(domains)
121+
return domains
122+
}
123+
124+
func sortedTokenTypes(tokens map[string]storedCredential) []string {
125+
tokenTypes := make([]string, 0, len(tokens))
126+
for tokenType := range tokens {
127+
tokenTypes = append(tokenTypes, tokenType)
128+
}
129+
sort.Strings(tokenTypes)
130+
return tokenTypes
66131
}
67132

68133
// logoutCmd represents the logout command
69134
var logoutCmd = &cobra.Command{
70135
Use: "logout [flags]",
71136
Short: "Log out of the current session",
137+
Long: `Log out of the current session.
138+
139+
Logout revokes saved Dropbox access tokens by default and removes local saved
140+
credentials. If DBXCLI_ACCESS_TOKEN is set, unset it before running logout;
141+
environment-provided tokens are not saved locally and cannot be removed by
142+
dbxcli.`,
72143
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
73144
return validateOutputFormat(cmd)
74145
},
@@ -77,4 +148,5 @@ var logoutCmd = &cobra.Command{
77148

78149
func init() {
79150
RootCmd.AddCommand(logoutCmd)
151+
enableStructuredOutput(logoutCmd)
80152
}

0 commit comments

Comments
 (0)