diff --git a/cmd/account.go b/cmd/account.go index b345189..3af160c 100644 --- a/cmd/account.go +++ b/cmd/account.go @@ -30,6 +30,7 @@ type accountInput struct { type jsonAccount struct { Type string `json:"type"` AccountID string `json:"account_id"` + Auth *accountAuth `json:"auth,omitempty"` Name *jsonAccountName `json:"name,omitempty"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` @@ -58,13 +59,19 @@ type jsonAccountTeam struct { MemberID string `json:"member_id,omitempty"` } +type accountAuth struct { + Source string `json:"source"` + Refreshable bool `json:"refreshable"` + AuthFile string `json:"auth_file,omitempty"` +} + const ( accountJSONStatusFound = "found" accountKindAccount = "account" ) // renderFullAccount prints the account details returned by GetCurrentAccount. -func renderFullAccount(out io.Writer, fa *users.FullAccount) error { +func renderFullAccount(out io.Writer, fa *users.FullAccount, auth *accountAuth) error { w := new(tabwriter.Writer) w.Init(out, 4, 8, 1, ' ', 0) @@ -73,17 +80,20 @@ func renderFullAccount(out io.Writer, fa *users.FullAccount) error { fmt.Fprintf(w, "Account Type:\t%s\n", fa.AccountType.Tag) fmt.Fprintf(w, "Locale:\t%s\n", fa.Locale) fmt.Fprintf(w, "Referral Link:\t%s\n", fa.ReferralLink) - fmt.Fprintf(w, "Profile Photo Url:\t%s\n", fa.ProfilePhotoUrl) + if fa.ProfilePhotoUrl != "" { + fmt.Fprintf(w, "Profile Photo URL:\t%s\n", fa.ProfilePhotoUrl) + } fmt.Fprintf(w, "Paired Account:\t%t\n", fa.IsPaired) if fa.Team != nil { fmt.Fprintf(w, "Team:\n Name:\t%s\n Id:\t%s\n Member Id:\t%s\n", fa.Team.Name, fa.Team.Id, fa.TeamMemberId) } + renderAccountAuth(w, auth) return w.Flush() } // renderBasicAccount prints the account details returned by GetAccount. -func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error { +func renderBasicAccount(out io.Writer, ba *users.BasicAccount, auth *accountAuth) error { w := new(tabwriter.Writer) w.Init(out, 4, 8, 1, ' ', 0) @@ -97,11 +107,37 @@ func renderBasicAccount(out io.Writer, ba *users.BasicAccount) error { if ba.TeamMemberId != "" { fmt.Fprintf(w, "Team Member Id:\t%s\n", ba.TeamMemberId) } - fmt.Fprintf(w, "Profile Photo URL:\t%s\n", ba.ProfilePhotoUrl) + if ba.ProfilePhotoUrl != "" { + fmt.Fprintf(w, "Profile Photo URL:\t%s\n", ba.ProfilePhotoUrl) + } + renderAccountAuth(w, auth) return w.Flush() } +func renderAccountAuth(out io.Writer, auth *accountAuth) { + if auth == nil { + return + } + + authFile := auth.AuthFile + if authFile == "" { + authFile = authFileNone + } + fmt.Fprintf(out, "\nAuth:\n Source:\t%s\n Refreshable:\t%t\n Auth File:\t%s\n", accountAuthSourceText(auth.Source), auth.Refreshable, authFile) +} + +func accountAuthSourceText(source string) string { + switch source { + case authSourceSaved: + return "saved credentials" + case authSourceEnv: + return envAccessToken + default: + return source + } +} + func account(cmd *cobra.Command, args []string) error { if len(args) > 1 { return invalidArgumentsErrorWithDetails("`account` accepts an optional `id` argument", argumentErrorDetails("id")) @@ -109,6 +145,7 @@ func account(cmd *cobra.Command, args []string) error { dbx := usersNewFunc(config) out := commandOutput(cmd) + auth := accountAuthFromContext(currentAuthContext) if len(args) == 0 { // If no arguments are provided get the current user's account @@ -118,8 +155,8 @@ func account(cmd *cobra.Command, args []string) error { } input := accountInput{} return out.Render(func(w io.Writer) error { - return renderFullAccount(w, res) - }, withJSONCommand(cmd, newAccountOperationOutput(input, jsonFullAccount(res)))) + return renderFullAccount(w, res, auth) + }, withJSONCommand(cmd, newAccountOperationOutput(input, jsonFullAccount(res, auth)))) } // Otherwise look up an account with the provided ID @@ -132,8 +169,8 @@ func account(cmd *cobra.Command, args []string) error { AccountID: args[0], } return out.Render(func(w io.Writer) error { - return renderBasicAccount(w, res) - }, withJSONCommand(cmd, newAccountOperationOutput(input, jsonBasicAccount(res)))) + return renderBasicAccount(w, res, auth) + }, withJSONCommand(cmd, newAccountOperationOutput(input, jsonBasicAccount(res, auth)))) } func newAccountOperationOutput(input accountInput, account jsonAccount) jsonOperationOutput { @@ -142,8 +179,9 @@ func newAccountOperationOutput(input accountInput, account jsonAccount) jsonOper }, nil) } -func jsonFullAccount(fa *users.FullAccount) jsonAccount { +func jsonFullAccount(fa *users.FullAccount, auth *accountAuth) jsonAccount { account := jsonAccountFromBase("full", fa.Account) + account.Auth = auth account.Locale = fa.Locale account.ReferralLink = fa.ReferralLink account.IsPaired = boolPtr(fa.IsPaired) @@ -161,13 +199,25 @@ func jsonFullAccount(fa *users.FullAccount) jsonAccount { return account } -func jsonBasicAccount(ba *users.BasicAccount) jsonAccount { +func jsonBasicAccount(ba *users.BasicAccount, auth *accountAuth) jsonAccount { account := jsonAccountFromBase("basic", ba.Account) + account.Auth = auth account.IsTeammate = boolPtr(ba.IsTeammate) account.TeamMemberID = ba.TeamMemberId return account } +func accountAuthFromContext(ctx *authContext) *accountAuth { + if ctx == nil || ctx.Source == "" { + return nil + } + return &accountAuth{ + Source: ctx.Source, + Refreshable: ctx.Refreshable, + AuthFile: ctx.AuthFile, + } +} + func jsonAccountFromBase(accountType string, account users.Account) jsonAccount { return jsonAccount{ Type: accountType, diff --git a/cmd/account_test.go b/cmd/account_test.go index e66c869..7da2221 100644 --- a/cmd/account_test.go +++ b/cmd/account_test.go @@ -15,6 +15,7 @@ import ( func TestAccountCurrentTextUsesCommandOutput(t *testing.T) { cmd, stdout := testAccountCmd() + setCurrentAuthContextForTest(t, &authContext{Source: authSourceSaved, Refreshable: true, AuthFile: authFileDefault}) stubUsersClient(t, &mockUsersClient{ getCurrentAccountFn: func() (*users.FullAccount, error) { return testFullAccount(), nil @@ -31,11 +32,24 @@ func TestAccountCurrentTextUsesCommandOutput(t *testing.T) { if !strings.Contains(output, "Account Type:") { t.Fatalf("stdout = %q, want account type", output) } + if !strings.Contains(output, "Auth:") || !strings.Contains(output, "Source: saved credentials") || !strings.Contains(output, "Refreshable: true") || !strings.Contains(output, "Auth File: default") { + t.Fatalf("stdout = %q, want auth context", output) + } + if !strings.Contains(output, "Profile Photo URL:") { + t.Fatalf("stdout = %q, want Profile Photo URL label", output) + } + if strings.Contains(output, "Profile Photo Url:") { + t.Fatalf("stdout = %q, want URL acronym casing", output) + } + if strings.Contains(output, "Acting As:") { + t.Fatalf("stdout = %q, want no Acting As section", output) + } } func TestAccountCurrentJSONOutputsAccount(t *testing.T) { cmd, stdout := testAccountCmd() setAccountOutputJSON(t, cmd) + setCurrentAuthContextForTest(t, &authContext{Source: authSourceSaved, Refreshable: true, AuthFile: authFileDefault}) stubUsersClient(t, &mockUsersClient{ getCurrentAccountFn: func() (*users.FullAccount, error) { return testFullAccount(), nil @@ -73,11 +87,13 @@ func TestAccountCurrentJSONOutputsAccount(t *testing.T) { if account.Team == nil || account.Team.ID != "team-id" || account.Team.MemberID != "dbmid:member" { t.Fatalf("team = %#v, want team metadata", account.Team) } + assertAccountAuth(t, account.Auth, authSourceSaved, true, authFileDefault) } func TestAccountLookupJSONUsesAccountID(t *testing.T) { cmd, stdout := testAccountCmd() setAccountOutputJSON(t, cmd) + setCurrentAuthContextForTest(t, &authContext{Source: authSourceEnv, Refreshable: false, AuthFile: authFileNone}) var gotArg *users.GetAccountArg stubUsersClient(t, &mockUsersClient{ getAccountFn: func(arg *users.GetAccountArg) (*users.BasicAccount, error) { @@ -111,6 +127,32 @@ func TestAccountLookupJSONUsesAccountID(t *testing.T) { if account.IsTeammate == nil || *account.IsTeammate { t.Fatalf("is_teammate = %#v, want false pointer", account.IsTeammate) } + assertAccountAuth(t, account.Auth, authSourceEnv, false, authFileNone) +} + +func TestAccountLookupTextIncludesAuth(t *testing.T) { + cmd, stdout := testAccountCmd() + setCurrentAuthContextForTest(t, &authContext{Source: authSourceEnv, Refreshable: false, AuthFile: authFileNone}) + stubUsersClient(t, &mockUsersClient{ + getAccountFn: func(arg *users.GetAccountArg) (*users.BasicAccount, error) { + return testBasicAccount(), nil + }, + }) + + if err := account(cmd, []string{"dbid:lookup"}); err != nil { + t.Fatalf("account returned error: %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "Auth:") || !strings.Contains(output, "Source: DBXCLI_ACCESS_TOKEN") || !strings.Contains(output, "Refreshable: false") || !strings.Contains(output, "Auth File: none") { + t.Fatalf("stdout = %q, want env auth context", output) + } + if strings.Contains(output, "Profile Photo URL:") { + t.Fatalf("stdout = %q, want empty profile photo omitted", output) + } + if strings.Contains(output, "Acting As:") { + t.Fatalf("stdout = %q, want no Acting As section", output) + } } func TestAccountJSONErrorWritesNoOutput(t *testing.T) { @@ -151,6 +193,16 @@ func setAccountOutputJSON(t *testing.T, cmd *cobra.Command) { } } +func setCurrentAuthContextForTest(t *testing.T, ctx *authContext) { + t.Helper() + + orig := currentAuthContext + currentAuthContext = ctx + t.Cleanup(func() { + currentAuthContext = orig + }) +} + type accountJSONOutput struct { Input accountInput `json:"input"` Results []accountJSONResult `json:"results"` @@ -182,6 +234,17 @@ func decodeAccountOutput(t *testing.T, out *bytes.Buffer) accountJSONOutput { return got } +func assertAccountAuth(t *testing.T, got *accountAuth, source string, refreshable bool, authFile string) { + t.Helper() + + if got == nil { + t.Fatal("auth = nil, want auth context") + } + if got.Source != source || got.Refreshable != refreshable || got.AuthFile != authFile { + t.Fatalf("auth = %#v, want source=%q refreshable=%t auth_file=%q", got, source, refreshable, authFile) + } +} + func testFullAccount() *users.FullAccount { account := users.NewFullAccount( "dbid:current", diff --git a/cmd/auth.go b/cmd/auth.go index 84c3276..07526e7 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -35,6 +35,13 @@ const ( tokenAccessTypeParam = "token_access_type" tokenAccessTypeOffline = "offline" tokenRefreshWindow = 5 * time.Minute + + authSourceEnv = "env" + authSourceSaved = "saved" + + authFileDefault = "default" + authFileCustom = "custom" + authFileNone = "none" ) // TokenMap maps domains to a map of commands to saved credentials. @@ -54,6 +61,14 @@ type appCredentials struct { Key string } +type authContext struct { + Source string + Refreshable bool + AuthFile string +} + +var currentAuthContext *authContext + var readAppKey = func(prompt string) (string, error) { fmt.Print(prompt) var value string @@ -174,6 +189,13 @@ func authFilePath() (string, error) { return filepath.Join(dir, ".config", "dbxcli", configFileName), nil } +func authFileKind() string { + if os.Getenv(envAuthFile) != "" { + return authFileCustom + } + return authFileDefault +} + func readTokens(filePath string) (TokenMap, error) { b, err := os.ReadFile(filePath) if err != nil { @@ -202,15 +224,15 @@ func writeTokens(filePath string, tokens TokenMap) error { return os.WriteFile(filePath, b, 0600) } -func getAccessToken(tokType string, domain string, force bool) (string, string, error) { +func getAccessCredential(tokType string, domain string, force bool) (storedCredential, string, error) { filePath, err := authFilePath() if err != nil { - return "", "", err + return storedCredential{}, "", err } tokenMap, err := readTokens(filePath) if err != nil && !os.IsNotExist(err) { - return "", "", fmt.Errorf("read auth file %q: %w", filePath, err) + return storedCredential{}, "", fmt.Errorf("read auth file %q: %w", filePath, err) } if tokenMap == nil { tokenMap = make(TokenMap) @@ -223,15 +245,15 @@ func getAccessToken(tokType string, domain string, force bool) (string, string, if force || (credential.AccessToken == "" && credential.RefreshToken == "") { if !force { - return "", "", missingAccessTokenError(tokType) + return storedCredential{}, "", missingAccessTokenError(tokType) } credential, err = requestAccessCredential(tokType, domain) if err != nil { - return "", "", err + return storedCredential{}, "", err } tokens[tokType] = credential if err = writeTokens(filePath, tokenMap); err != nil { - return "", "", err + return storedCredential{}, "", err } } @@ -240,16 +262,24 @@ func getAccessToken(tokType string, domain string, force bool) (string, string, if err != nil { details := authTokenDetails(tokType) if jsonErrorCode(err) == jsonErrorCodeAppKeyRequired { - return "", "", appKeyRequiredErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType)) + return storedCredential{}, "", appKeyRequiredErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType)) } - return "", "", authRefreshFailedErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType)) + return storedCredential{}, "", authRefreshFailedErrorfWithDetails("refresh saved Dropbox credentials: %w; run %q again", details, err, loginCommand(tokType)) } tokens[tokType] = credential if err = writeTokens(filePath, tokenMap); err != nil { - return "", "", err + return storedCredential{}, "", err } } + return credential, filePath, nil +} + +func getAccessToken(tokType string, domain string, force bool) (string, string, error) { + credential, filePath, err := getAccessCredential(tokType, domain, force) + if err != nil { + return "", "", err + } return credential.AccessToken, filePath, nil } diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index cea1f1c..57d1c1e 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -145,6 +145,21 @@ func TestStructuredOutputGoldenSuccessOutputAudit(t *testing.T) { } } +func TestAccountAuthContractOmitsSensitiveFields(t *testing.T) { + example := jsonGoldenSuccessOutputExamples()["account"] + encoded, err := json.Marshal(example) + if err != nil { + t.Fatal(err) + } + + output := string(encoded) + for _, forbidden := range []string{"access_token", "refresh_token", "app_key", "auth.json", ".config"} { + if strings.Contains(output, forbidden) { + t.Fatalf("account JSON contains %q: %s", forbidden, output) + } + } +} + func TestPublicJSONSchemaFiles(t *testing.T) { tests := []struct { file string @@ -701,6 +716,7 @@ func sampleJSONAccount() jsonAccount { return jsonAccount{ Type: "full", AccountID: "dbid:account", + Auth: &accountAuth{Source: authSourceSaved, Refreshable: true, AuthFile: authFileDefault}, Name: &jsonAccountName{GivenName: "Ada", Surname: "Lovelace", FamiliarName: "Ada", DisplayName: "Ada Lovelace", AbbreviatedName: "AL"}, Email: "ada@example.com", EmailVerified: true, @@ -818,6 +834,7 @@ func jsonContractStringPtr(value string) *string { func jsonContractDefinitions() map[string][]string { return normalizeStringSliceMap(map[string][]string{ "account": jsonFieldNames[jsonAccount](), + "account_auth": jsonFieldNames[accountAuth](), "account_input": jsonFieldNames[accountInput](), "account_name": jsonFieldNames[jsonAccountName](), "account_team": jsonFieldNames[jsonAccountTeam](), diff --git a/cmd/root.go b/cmd/root.go index c9fd24d..e063a7c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -145,6 +145,8 @@ func makeDropboxConfig(token string, verbose bool, asMember string, domain strin } func initDbx(cmd *cobra.Command, args []string) (err error) { + currentAuthContext = nil + if commandIsJSONHelp(cmd) { return nil } @@ -162,17 +164,27 @@ func initDbx(cmd *cobra.Command, args []string) (err error) { if accessToken := os.Getenv(envAccessToken); accessToken != "" { config = makeDropboxConfig(accessToken, verbose, asMember, domain) + currentAuthContext = &authContext{ + Source: authSourceEnv, + Refreshable: false, + AuthFile: authFileNone, + } config = withRootNamespace(config, tokenType(cmd)) return nil } tokType := tokenType(cmd) - accessToken, _, err := getAccessToken(tokType, domain, false) + credential, _, err := getAccessCredential(tokType, domain, false) if err != nil { return err } - config = makeDropboxConfig(accessToken, verbose, asMember, domain) + config = makeDropboxConfig(credential.AccessToken, verbose, asMember, domain) + currentAuthContext = &authContext{ + Source: authSourceSaved, + Refreshable: credential.RefreshToken != "", + AuthFile: authFileKind(), + } config = withRootNamespace(config, tokType) return diff --git a/cmd/root_test.go b/cmd/root_test.go index 98ea33d..1fd4f66 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -266,6 +266,7 @@ func TestInitDbxUsesAccessTokenEnv(t *testing.T) { if config.PathRoot != `{".tag": "root", "root": "root-ns"}` { t.Fatalf("expected path root from env token account, got %q", config.PathRoot) } + assertCurrentAuthContext(t, authSourceEnv, false, authFileNone) } func TestInitDbxAccessTokenEnvTakesPrecedenceOverAuthFile(t *testing.T) { @@ -296,6 +297,7 @@ func TestInitDbxAccessTokenEnvTakesPrecedenceOverAuthFile(t *testing.T) { if config.PathRoot != `{".tag": "root", "root": "env-root"}` { t.Fatalf("expected path root from env token account, got %q", config.PathRoot) } + assertCurrentAuthContext(t, authSourceEnv, false, authFileNone) } func TestInitDbxAccessTokenEnvBypassesRefresh(t *testing.T) { @@ -379,6 +381,47 @@ func TestInitDbxUsesAuthFileEnv(t *testing.T) { if config.PathRoot != `{".tag": "root", "root": "team-root"}` { t.Fatalf("expected path root from saved token account, got %q", config.PathRoot) } + assertCurrentAuthContext(t, authSourceSaved, false, authFileCustom) +} + +func TestInitDbxRecordsSavedRefreshableAuthContext(t *testing.T) { + origConfig := config + defer func() { config = origConfig }() + stubUsersClient(t, &mockUsersClient{ + getCurrentAccountFn: func() (*users.FullAccount, error) { + return testRootFullAccount(common.NewUserRootInfo("root-ns", "home-ns")), nil + }, + }) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(envAccessToken, "") + t.Setenv(envAuthFile, "") + + expiry := time.Now().Add(time.Hour) + authFile := filepath.Join(home, ".config", "dbxcli", "auth.json") + if err := writeTokens(authFile, TokenMap{ + "": { + tokenPersonal: { + AccessToken: "file-token", + RefreshToken: "refresh-token", + Expiry: &expiry, + AppKey: "app-key", + }, + }, + }); err != nil { + t.Fatal(err) + } + + cmd := newAuthTestCommand() + if err := initDbx(cmd, nil); err != nil { + t.Fatal(err) + } + + if config.Token != "file-token" { + t.Fatalf("expected saved token, got %q", config.Token) + } + assertCurrentAuthContext(t, authSourceSaved, true, authFileDefault) } func TestWithRootNamespaceSkipsTeamManage(t *testing.T) { @@ -397,6 +440,17 @@ func TestWithRootNamespaceSkipsTeamManage(t *testing.T) { } } +func assertCurrentAuthContext(t *testing.T, source string, refreshable bool, authFile string) { + t.Helper() + + if currentAuthContext == nil { + t.Fatal("currentAuthContext = nil") + } + if currentAuthContext.Source != source || currentAuthContext.Refreshable != refreshable || currentAuthContext.AuthFile != authFile { + t.Fatalf("currentAuthContext = %#v, want source=%q refreshable=%t auth_file=%q", currentAuthContext, source, refreshable, authFile) + } +} + func TestWithRootNamespaceKeepsConfigOnAccountError(t *testing.T) { cfg := makeDropboxConfig("token", false, "dbmid:member", "api.example.com") stubUsersClient(t, &mockUsersClient{ diff --git a/cmd/share_link_password.go b/cmd/share_link_password.go index d59e8ed..421e980 100644 --- a/cmd/share_link_password.go +++ b/cmd/share_link_password.go @@ -74,7 +74,7 @@ func sharedLinkPasswordFromFlags(cmd *cobra.Command) (sharedLinkPasswordOptions, password, err = cmd.Flags().GetString("password") case passwordPrompt: password, err = readSharedLinkPassword("Shared link password: ", cmd.InOrStdin(), cmd.ErrOrStderr()) - case passwordFile != "": + default: password, err = sharedLinkPasswordFromFile(passwordFile) } if err != nil { diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index 732a3ba..2f6b2e6 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -1,5 +1,5 @@ { - "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":[]}, + "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","auth":{"source":"saved","refreshable":true,"auth_file":"default"},"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":[]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 1d3172a..ffe2f1c 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -3,6 +3,7 @@ "account": [ "account_id", "account_type", + "auth", "disabled", "email", "email_verified", @@ -16,6 +17,11 @@ "team_member_id", "type" ], + "account_auth": [ + "auth_file", + "refreshable", + "source" + ], "account_input": [ "account_id" ], diff --git a/docs/automation.md b/docs/automation.md index fb58ef6..96d8b1d 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -91,7 +91,7 @@ Prefer flags that make automation outcomes explicit: ```sh dbxcli put --if-exists fail report.md /Reports/report.md dbxcli put --if-exists skip report.md /Reports/report.md --output=json -dbxcli rm /Reports/old-report.md --output=json +dbxcli share-link create /Reports/report.md --output=json ``` Use `--output=json` when the caller needs stable statuses, result kinds, @@ -112,6 +112,27 @@ Use `dbxcli login` to save refreshable credentials: dbxcli login ``` +Use `dbxcli account --output=json` as an auth and identity check. The account +result includes `result.auth`: + +`result.auth` example: + +```json +{ + "auth": { + "source": "saved", + "refreshable": true, + "auth_file": "default" + } +} +``` + +Stable auth fields: + +* `result.auth.source`: `saved` or `env` +* `result.auth.refreshable`: boolean +* `result.auth.auth_file`: `default`, `custom`, or `none` + Use `DBXCLI_ACCESS_TOKEN` for automation with short-lived Dropbox access tokens: ```sh @@ -121,6 +142,18 @@ DBXCLI_ACCESS_TOKEN=sl.xxxxxx dbxcli ls --output=json / This token is used directly and is not saved or refreshed. If it expires, the command fails and you must provide a fresh token. +`result.auth` example: + +```json +{ + "auth": { + "source": "env", + "refreshable": false, + "auth_file": "none" + } +} +``` + Set `DBXCLI_AUTH_FILE` to use a different saved credentials file: ```sh @@ -128,10 +161,22 @@ DBXCLI_AUTH_FILE=/path/to/auth.json dbxcli login DBXCLI_AUTH_FILE=/path/to/auth.json dbxcli ls / ``` +`result.auth` example: + +```json +{ + "auth": { + "source": "saved", + "refreshable": true, + "auth_file": "custom" + } +} +``` + `dbxcli logout` revokes saved Dropbox tokens 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. +credentials. Environment-provided tokens are not saved locally, so `dbxcli` +cannot remove them. If `DBXCLI_ACCESS_TOKEN` is set, unset it before running +logout. ## Stdin and stdout diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index 3b87736..c5709af 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -1,7 +1,8 @@ # dbxcli JSON schema v1 These schemas describe the stable top-level JSON envelopes emitted by -`dbxcli --output=json`. +`dbxcli --output=json` and JSON help responses emitted by +`dbxcli --help --output=json`. - `success.schema.json` validates successful command responses. - `error.schema.json` validates command error responses. @@ -21,9 +22,12 @@ Successful responses always include: Schema v1 is intended to be stable. New fields, commands, warning codes, and error details may be added in minor releases. Existing top-level fields, -existing stable error codes, and existing result status meanings should not be +existing stable error codes, and existing result status meanings will not be removed or renamed within schema v1. +JSON responses must not include access tokens, refresh tokens, authorization +codes, or app secrets. + Error responses always include: - `ok: false` @@ -50,13 +54,12 @@ available as a JSON command manifest with `--help --output=json`; for example, continues to print text help, and shell-completion protocol commands remain text-only. -Current JSON-enabled command paths include `version`, `account`, `du`, `ls`, -`search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, -`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`, `restore`, and -`logout`. +The current JSON-enabled command paths are listed in `commands.json`. + +`account` results include auth context under `result.auth`: +`result.auth.source` is `saved` or `env`; `result.auth.refreshable` is a +boolean; and `result.auth.auth_file` is `default`, `custom`, or `none`. +dbxcli does not include the full auth file path by default. Warnings are objects with a stable `code` and human-readable `message`; they may include optional command-specific details. Current warning codes include diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 1d3172a..ffe2f1c 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -3,6 +3,7 @@ "account": [ "account_id", "account_type", + "auth", "disabled", "email", "email_verified", @@ -16,6 +17,11 @@ "team_member_id", "type" ], + "account_auth": [ + "auth_file", + "refreshable", + "source" + ], "account_input": [ "account_id" ],