From d26f4e301998d6e0109f24164135bde7864dfca3 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Sun, 28 Jun 2026 00:11:42 -0700 Subject: [PATCH] Add auth context to account JSON output Include result.auth in account command output so scripts can identify the credential source (saved vs env), whether credentials are refreshable, and the auth file type (default, custom, none). --- cmd/account.go | 70 ++++++++++++++++--- cmd/account_test.go | 63 +++++++++++++++++ cmd/auth.go | 48 ++++++++++--- cmd/json_contract_test.go | 17 +++++ cmd/root.go | 16 ++++- cmd/root_test.go | 54 ++++++++++++++ cmd/share_link_password.go | 2 +- .../json_contract/success_outputs.json | 2 +- .../json_contract/success_schemas.json | 6 ++ docs/automation.md | 53 ++++++++++++-- docs/json-schema/v1/README.md | 21 +++--- docs/json-schema/v1/commands.json | 6 ++ 12 files changed, 322 insertions(+), 36 deletions(-) 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" ],