From 2a17d15cfa98d4aa135d203041bdbe56c82f3e48 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 10:04:11 +0000 Subject: [PATCH 1/9] Add auth logout command with --profile and --force flags Implement the initial version of databricks auth logout which removes a profile from ~/.databrickscfg and clears associated OAuth tokens from the token cache. This iteration supports explicit profile selection via --profile and a --force flag to skip the confirmation prompt. Interactive profile selection will be added in a follow-up. Token cache cleanup is best-effort: the profile-keyed token is always removed, and the host-keyed token is removed only when no other profile references the same host. --- cmd/auth/auth.go | 1 + cmd/auth/logout.go | 170 +++++++++++++++++++++++++++++++++ cmd/auth/logout_test.go | 163 +++++++++++++++++++++++++++++++ libs/databrickscfg/ops.go | 39 ++++++++ libs/databrickscfg/ops_test.go | 63 ++++++++++++ 5 files changed, 436 insertions(+) create mode 100644 cmd/auth/logout.go create mode 100644 cmd/auth/logout_test.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 4c783fd0e6..b06cf8945c 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -30,6 +30,7 @@ GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html`, cmd.AddCommand(newEnvCommand()) cmd.AddCommand(newLoginCommand(&authArguments)) + cmd.AddCommand(newLogoutCommand()) cmd.AddCommand(newProfilesCommand()) cmd.AddCommand(newTokenCommand(&authArguments)) cmd.AddCommand(newDescribeCommand()) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 0000000000..9f239d2249 --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,170 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" + "github.com/spf13/cobra" +) + +func newLogoutCommand() *cobra.Command { + defaultConfigPath := "~/.databrickscfg" + if runtime.GOOS == "windows" { + defaultConfigPath = "%USERPROFILE%\\.databrickscfg" + } + + cmd := &cobra.Command{ + Use: "logout", + Short: "Log out of a Databricks profile", + Long: fmt.Sprintf(`Log out of a Databricks profile. + +This command removes the specified profile from %s and deletes +any associated cached OAuth tokens. + +You will need to run "databricks auth login" to re-authenticate after +logging out.`, defaultConfigPath), + } + + var force bool + var profileName string + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&profileName, "profile", "", "The profile to log out of") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if profileName == "" { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile") + } + return errors.New("please specify a profile to log out of using --profile") + } + + tokenCache, err := cache.NewFileTokenCache() + if err != nil { + log.Warnf(ctx, "Failed to open token cache: %v", err) + } + + return runLogout(ctx, logoutArgs{ + profileName: profileName, + force: force, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: os.Getenv("DATABRICKS_CONFIG_FILE"), + }) + } + + return cmd +} + +type logoutArgs struct { + profileName string + force bool + profiler profile.Profiler + tokenCache cache.TokenCache + configFilePath string +} + +func runLogout(ctx context.Context, args logoutArgs) error { + matchedProfile, err := getMatchingProfile(ctx, args.profileName, args.profiler) + if err != nil { + return err + } + + if !args.force { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("please specify --force to skip confirmation in non-interactive mode") + } + + question := fmt.Sprintf( + "WARNING: This will remove profile %q from %s and delete "+ + "any cached OAuth tokens associated with it. You will need to run "+ + "\"databricks auth login\" to re-authenticate.\n\nAre you sure?", + args.profileName, args.configFilePath) + + approved, err := cmdio.AskYesOrNo(ctx, question) + if err != nil { + return err + } + if !approved { + return nil + } + } + + clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) + + err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) + if err != nil { + return fmt.Errorf("failed to remove profile: %w", err) + } + + return nil +} + +// getMatchingProfile loads a profile by name and returns an error with +// available profile names if the profile is not found. +func getMatchingProfile(ctx context.Context, profileName string, profiler profile.Profiler) (*profile.Profile, error) { + if profiler == nil { + return nil, errors.New("profiler cannot be nil") + } + + profiles, err := profiler.LoadProfiles(ctx, profile.WithName(profileName)) + if err != nil { + return nil, err + } + + if len(profiles) == 0 { + allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return nil, fmt.Errorf("profile %q not found", profileName) + } + + return nil, fmt.Errorf("profile %q not found. Available profiles: %s", profileName, allProfiles.Names()) + } + + return &profiles[0], nil +} + +// clearTokenCache removes cached OAuth tokens for the given profile from the +// token cache. It removes: +// 1. The entry keyed by the profile name. +// 2. The entry keyed by the host URL, but only if no other remaining profile +// references the same host. +func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) { + if tokenCache == nil { + return + } + + profileName := p.Name + if err := tokenCache.Store(profileName, nil); err != nil { + log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", profileName, err) + } + + host := strings.TrimRight(p.Host, "/") + if host == "" { + return + } + + otherProfilesUsingHost, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { + return candidate.Name != profileName && profile.WithHost(host)(candidate) + }) + if err != nil { + log.Warnf(ctx, "Failed to load profiles using host %q: %v", host, err) + return + } + + if len(otherProfilesUsingHost) == 0 { + if err := tokenCache.Store(host, nil); err != nil { + log.Warnf(ctx, "Failed to delete host-keyed token for host %q: %v", host, err) + } + } +} diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go new file mode 100644 index 0000000000..320796007d --- /dev/null +++ b/cmd/auth/logout_test.go @@ -0,0 +1,163 @@ +package auth + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +const logoutTestConfig = `[DEFAULT] +[my-workspace] +host = https://my-workspace.cloud.databricks.com + +[staging] +host = https://staging.cloud.databricks.com + +[shared-host-1] +host = https://shared.cloud.databricks.com + +[shared-host-2] +host = https://shared.cloud.databricks.com +` + +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLogout(t *testing.T) { + cases := []struct { + name string + profileName string + force bool + wantErr string + }{ + { + name: "existing profile with force", + profileName: "my-workspace", + force: true, + }, + { + name: "existing profile without force in non-interactive mode", + profileName: "my-workspace", + force: false, + wantErr: "please specify --force to skip confirmation in non-interactive mode", + }, + { + name: "non-existing profile with force", + profileName: "nonexistent", + force: true, + wantErr: `profile "nonexistent" not found`, + }, + { + name: "non-existing profile without force", + profileName: "nonexistent", + force: false, + wantErr: `profile "nonexistent" not found`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "token1"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "token1"}, + }, + } + + err := runLogout(ctx, logoutArgs{ + profileName: tc.profileName, + force: tc.force, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + // Verify profile was removed from config. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(tc.profileName)) + require.NoError(t, err) + assert.Empty(t, profiles) + + // Verify tokens were cleaned up. + assert.Nil(t, tokenCache.Tokens["my-workspace"]) + }) + } +} + +func TestLogoutSharedHost(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{ + "shared-host-1": {AccessToken: "token1"}, + "shared-host-2": {AccessToken: "token2"}, + "https://shared.cloud.databricks.com": {AccessToken: "shared-token"}, + "https://staging.cloud.databricks.com": {AccessToken: "staging-token"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "ws-token"}, + }, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "shared-host-1", + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + // Profile-keyed token should be removed. + assert.Nil(t, tokenCache.Tokens["shared-host-1"]) + + // Host-keyed token should be preserved because shared-host-2 still uses it. + assert.NotNil(t, tokenCache.Tokens["https://shared.cloud.databricks.com"]) + + // Other profiles' tokens should be untouched. + assert.NotNil(t, tokenCache.Tokens["shared-host-2"]) + assert.NotNil(t, tokenCache.Tokens["https://staging.cloud.databricks.com"]) +} + +func TestLogoutNoTokens(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{}, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "my-workspace", + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + // Profile should still be removed from config even without cached tokens. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName("my-workspace")) + require.NoError(t, err) + assert.Empty(t, profiles) +} diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index e38a0bd69b..daed526ed8 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -152,6 +152,45 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) return configFile.SaveTo(configFile.Path()) } +// DeleteProfile removes the named profile section from the databrickscfg file. +// It creates a backup of the original file before modifying it. +func DeleteProfile(ctx context.Context, profileName, configFilePath string) error { + configFile, err := loadOrCreateConfigFile(configFilePath) + if err != nil { + return err + } + + _, err = findMatchingProfile(configFile, func(s *ini.Section) bool { + return s.Name() == profileName + }) + if err != nil { + return fmt.Errorf("profile %q not found in %s", profileName, configFile.Path()) + } + + configFile.DeleteSection(profileName) + + section := configFile.Section(ini.DefaultSection) + if len(section.Keys()) == 0 && section.Comment == "" { + section.Comment = defaultComment + } + + orig, backupErr := os.ReadFile(configFile.Path()) + if len(orig) > 0 && backupErr == nil { + log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) + err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) + if err != nil { + return fmt.Errorf("backup: %w", err) + } + log.Infof(ctx, "Overwriting %s", configFile.Path()) + } else if backupErr != nil { + log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", + configFile.Path(), backupErr) + } else { + log.Infof(ctx, "Saving %s", configFile.Path()) + } + return configFile.SaveTo(configFile.Path()) +} + func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { configFile, err := config.LoadFile(cfg.ConfigFile) if err != nil { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 646ed1e54a..bc75935fbe 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -278,3 +278,66 @@ func TestSaveToProfile_MergeSemantics(t *testing.T) { }) } } + +func TestDeleteProfile(t *testing.T) { + seedConfig := `; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[first] +host = https://first.cloud.databricks.com + +[second] +host = https://second.cloud.databricks.com +` + + cases := []struct { + name string + profileToDelete string + configFilePath string + wantErr string + wantRemainingSectionCnt int + }{ + { + name: "delete existing profile", + profileToDelete: "first", + wantRemainingSectionCnt: 2, // DEFAULT + second + }, + { + name: "profile not found", + profileToDelete: "nonexistent", + wantErr: `profile "nonexistent" not found`, + }, + { + name: "custom config path", + profileToDelete: "second", + configFilePath: "custom", + wantRemainingSectionCnt: 2, // DEFAULT + first + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + filename := ".databrickscfg" + if tc.configFilePath != "" { + filename = tc.configFilePath + } + path := filepath.Join(dir, filename) + require.NoError(t, os.WriteFile(path, []byte(seedConfig), fileMode)) + + err := DeleteProfile(ctx, tc.profileToDelete, path) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + file, err := loadOrCreateConfigFile(path) + require.NoError(t, err) + assert.Len(t, file.Sections(), tc.wantRemainingSectionCnt) + assert.False(t, file.HasSection(tc.profileToDelete)) + }) + } +} From a9dcdbc511e19f20b903c18e1d0fde18026b25cf Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 13:03:21 +0000 Subject: [PATCH 2/9] Improve auth logout confirmation with styled warning template Replace plain fmt.Sprintf confirmation prompt with a structured template using cmdio.RenderWithTemplate. The warning now uses color and bold formatting to clearly highlight the profile name, config path, and consequences before prompting for confirmation. --- cmd/auth/logout.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 9f239d2249..8674b56d2d 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -16,6 +16,15 @@ import ( "github.com/spf13/cobra" ) +const logoutWarningTemplate = `{{ "Warning" | yellow }}: This will permanently log out of profile {{ .ProfileName | bold }}. + +The following changes will be made: + - Remove profile {{ .ProfileName | bold }} from {{ .ConfigPath }} + - Delete any cached OAuth tokens for this profile + +You will need to run {{ "databricks auth login" | bold }} to re-authenticate. +` + func newLogoutCommand() *cobra.Command { defaultConfigPath := "~/.databrickscfg" if runtime.GOOS == "windows" { @@ -85,13 +94,19 @@ func runLogout(ctx context.Context, args logoutArgs) error { return errors.New("please specify --force to skip confirmation in non-interactive mode") } - question := fmt.Sprintf( - "WARNING: This will remove profile %q from %s and delete "+ - "any cached OAuth tokens associated with it. You will need to run "+ - "\"databricks auth login\" to re-authenticate.\n\nAre you sure?", - args.profileName, args.configFilePath) + configPath := args.configFilePath + if configPath == "" { + configPath = "~/.databrickscfg" + } + err := cmdio.RenderWithTemplate(ctx, map[string]string{ + "ProfileName": args.profileName, + "ConfigPath": configPath, + }, "", logoutWarningTemplate) + if err != nil { + return err + } - approved, err := cmdio.AskYesOrNo(ctx, question) + approved, err := cmdio.AskYesOrNo(ctx, "Are you sure?") if err != nil { return err } From 20422b8ca1cdead87e88df414dc226412080dbc8 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 16:51:50 +0000 Subject: [PATCH 3/9] Address auth logout review feedback Resolve config path from the profiler instead of hardcoding fallbacks. Delete the profile before clearing the token cache so a config write failure does not leave tokens removed. Fix token cleanup for account and unified profiles by computing the correct OIDC cache key (host/oidc/accounts/). Drop the nil profiler guard, add a success message on logout, and extract backupConfigFile in ops.go to remove duplication. Consolidate token cleanup tests into a table-driven test covering shared hosts, unique hosts, account, and unified profiles. --- cmd/auth/logout.go | 63 ++++++++++++--------- cmd/auth/logout_test.go | 114 ++++++++++++++++++++++++++------------ libs/databrickscfg/ops.go | 59 ++++++++------------ 3 files changed, 142 insertions(+), 94 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 8674b56d2d..6827423aaa 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -32,8 +32,9 @@ func newLogoutCommand() *cobra.Command { } cmd := &cobra.Command{ - Use: "logout", - Short: "Log out of a Databricks profile", + Use: "logout", + Short: "Log out of a Databricks profile", + Hidden: true, Long: fmt.Sprintf(`Log out of a Databricks profile. This command removes the specified profile from %s and deletes @@ -94,11 +95,11 @@ func runLogout(ctx context.Context, args logoutArgs) error { return errors.New("please specify --force to skip confirmation in non-interactive mode") } - configPath := args.configFilePath - if configPath == "" { - configPath = "~/.databrickscfg" + configPath, err := args.profiler.GetPath(ctx) + if err != nil { + return err } - err := cmdio.RenderWithTemplate(ctx, map[string]string{ + err = cmdio.RenderWithTemplate(ctx, map[string]string{ "ProfileName": args.profileName, "ConfigPath": configPath, }, "", logoutWarningTemplate) @@ -115,23 +116,20 @@ func runLogout(ctx context.Context, args logoutArgs) error { } } - clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) - err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { return fmt.Errorf("failed to remove profile: %w", err) } + clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) + + cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of profile %q.", args.profileName)) return nil } // getMatchingProfile loads a profile by name and returns an error with // available profile names if the profile is not found. func getMatchingProfile(ctx context.Context, profileName string, profiler profile.Profiler) (*profile.Profile, error) { - if profiler == nil { - return nil, errors.New("profiler cannot be nil") - } - profiles, err := profiler.LoadProfiles(ctx, profile.WithName(profileName)) if err != nil { return nil, err @@ -152,34 +150,49 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil // clearTokenCache removes cached OAuth tokens for the given profile from the // token cache. It removes: // 1. The entry keyed by the profile name. -// 2. The entry keyed by the host URL, but only if no other remaining profile -// references the same host. +// 2. The entry keyed by the host-based cache key, but only if no other +// remaining profile references the same key. For account and unified +// profiles, the cache key includes the OIDC path +// (host/oidc/accounts/). func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) { if tokenCache == nil { return } - profileName := p.Name - if err := tokenCache.Store(profileName, nil); err != nil { - log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", profileName, err) + if err := tokenCache.Store(p.Name, nil); err != nil { + log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", p.Name, err) } - host := strings.TrimRight(p.Host, "/") - if host == "" { + hostCacheKey, matchFn := hostCacheKeyAndMatchFn(p) + if hostCacheKey == "" { return } - otherProfilesUsingHost, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { - return candidate.Name != profileName && profile.WithHost(host)(candidate) + otherProfiles, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { + return candidate.Name != p.Name && matchFn(candidate) }) if err != nil { - log.Warnf(ctx, "Failed to load profiles using host %q: %v", host, err) + log.Warnf(ctx, "Failed to load profiles for host cache key %q: %v", hostCacheKey, err) return } - if len(otherProfilesUsingHost) == 0 { - if err := tokenCache.Store(host, nil); err != nil { - log.Warnf(ctx, "Failed to delete host-keyed token for host %q: %v", host, err) + if len(otherProfiles) == 0 { + if err := tokenCache.Store(hostCacheKey, nil); err != nil { + log.Warnf(ctx, "Failed to delete host-keyed token for %q: %v", hostCacheKey, err) } } } + +// hostCacheKeyAndMatchFn returns the token cache key and a profile match +// function for the host-based token entry. Account and unified profiles use +// host/oidc/accounts/ as the cache key and match on both host and +// account ID; workspace profiles use just the host. +func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunction) { + host := strings.TrimRight(p.Host, "/") + + if p.AccountID != "" { + return host + "/oidc/accounts/" + p.AccountID, profile.WithHostAndAccountID(host, p.AccountID) + } + + return host, profile.WithHost(host) +} diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 320796007d..8003167116 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -17,14 +17,20 @@ const logoutTestConfig = `[DEFAULT] [my-workspace] host = https://my-workspace.cloud.databricks.com -[staging] -host = https://staging.cloud.databricks.com +[shared-workspace] +host = https://my-workspace.cloud.databricks.com + +[my-unique-workspace] +host = https://my-unique-workspace.cloud.databricks.com -[shared-host-1] -host = https://shared.cloud.databricks.com +[my-account] +host = https://accounts.cloud.databricks.com +account_id = abc123 -[shared-host-2] -host = https://shared.cloud.databricks.com +[my-unified] +host = https://unified.cloud.databricks.com +account_id = def456 +experimental_is_unified_host = true ` func writeTempConfig(t *testing.T, content string) string { @@ -98,44 +104,84 @@ func TestLogout(t *testing.T) { assert.Empty(t, profiles) // Verify tokens were cleaned up. - assert.Nil(t, tokenCache.Tokens["my-workspace"]) + assert.Nil(t, tokenCache.Tokens[tc.profileName]) }) } } -func TestLogoutSharedHost(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - configPath := writeTempConfig(t, logoutTestConfig) - t.Setenv("DATABRICKS_CONFIG_FILE", configPath) - - tokenCache := &inMemoryTokenCache{ - Tokens: map[string]*oauth2.Token{ - "shared-host-1": {AccessToken: "token1"}, - "shared-host-2": {AccessToken: "token2"}, - "https://shared.cloud.databricks.com": {AccessToken: "shared-token"}, - "https://staging.cloud.databricks.com": {AccessToken: "staging-token"}, - "https://my-workspace.cloud.databricks.com": {AccessToken: "ws-token"}, +func TestLogoutTokenCacheCleanup(t *testing.T) { + cases := []struct { + name string + profileName string + tokens map[string]*oauth2.Token + wantRemoved []string + wantPreserved []string + }{ + { + name: "workspace shared host preserves host-keyed token", + profileName: "my-workspace", + tokens: map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "token1"}, + "shared-workspace": {AccessToken: "token2"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "host-token"}, + }, + wantRemoved: []string{"my-workspace"}, + wantPreserved: []string{"https://my-workspace.cloud.databricks.com", "shared-workspace"}, + }, + { + name: "workspace unique host clears host-keyed token", + profileName: "my-unique-workspace", + tokens: map[string]*oauth2.Token{ + "my-unique-workspace": {AccessToken: "token1"}, + "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "host-token"}, + }, + wantRemoved: []string{"my-unique-workspace", "https://my-unique-workspace.cloud.databricks.com"}, + }, + { + name: "account profile clears OIDC-keyed token", + profileName: "my-account", + tokens: map[string]*oauth2.Token{ + "my-account": {AccessToken: "token1"}, + "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-token"}, + }, + wantRemoved: []string{"my-account", "https://accounts.cloud.databricks.com/oidc/accounts/abc123"}, + }, + { + name: "unified profile clears OIDC-keyed token", + profileName: "my-unified", + tokens: map[string]*oauth2.Token{ + "my-unified": {AccessToken: "token1"}, + "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-token"}, + }, + wantRemoved: []string{"my-unified", "https://unified.cloud.databricks.com/oidc/accounts/def456"}, }, } - err := runLogout(ctx, logoutArgs{ - profileName: "shared-host-1", - force: true, - profiler: profile.DefaultProfiler, - tokenCache: tokenCache, - configFilePath: configPath, - }) - require.NoError(t, err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) - // Profile-keyed token should be removed. - assert.Nil(t, tokenCache.Tokens["shared-host-1"]) + tokenCache := &inMemoryTokenCache{Tokens: tc.tokens} - // Host-keyed token should be preserved because shared-host-2 still uses it. - assert.NotNil(t, tokenCache.Tokens["https://shared.cloud.databricks.com"]) + err := runLogout(ctx, logoutArgs{ + profileName: tc.profileName, + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) - // Other profiles' tokens should be untouched. - assert.NotNil(t, tokenCache.Tokens["shared-host-2"]) - assert.NotNil(t, tokenCache.Tokens["https://staging.cloud.databricks.com"]) + for _, key := range tc.wantRemoved { + assert.Nil(t, tokenCache.Tokens[key], "expected token %q to be removed", key) + } + for _, key := range tc.wantPreserved { + assert.NotNil(t, tokenCache.Tokens[key], "expected token %q to be preserved", key) + } + }) + } } func TestLogoutNoTokens(t *testing.T) { diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index daed526ed8..e7d24d8164 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -94,6 +94,24 @@ func AuthCredentialKeys() []string { return keys } +func backupConfigFile(ctx context.Context, configFile *config.File) error { + orig, backupErr := os.ReadFile(configFile.Path()) + if len(orig) > 0 && backupErr == nil { + log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) + err := os.WriteFile(configFile.Path()+".bak", orig, fileMode) + if err != nil { + return fmt.Errorf("backup: %w", err) + } + log.Infof(ctx, "Overwriting %s", configFile.Path()) + } else if backupErr != nil { + log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", + configFile.Path(), backupErr) + } else { + log.Infof(ctx, "Saving %s", configFile.Path()) + } + return nil +} + // SaveToProfile merges the provided config into a .databrickscfg profile. // Non-zero fields in cfg overwrite existing values. Existing keys not // mentioned in cfg are preserved. Keys listed in clearKeys are explicitly @@ -135,19 +153,8 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) section.Comment = defaultComment } - orig, backupErr := os.ReadFile(configFile.Path()) - if len(orig) > 0 && backupErr == nil { - log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) - err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) - if err != nil { - return fmt.Errorf("backup: %w", err) - } - log.Infof(ctx, "Overwriting %s", configFile.Path()) - } else if backupErr != nil { - log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", - configFile.Path(), backupErr) - } else { - log.Infof(ctx, "Saving %s", configFile.Path()) + if err := backupConfigFile(ctx, configFile); err != nil { + return err } return configFile.SaveTo(configFile.Path()) } @@ -155,16 +162,9 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) // DeleteProfile removes the named profile section from the databrickscfg file. // It creates a backup of the original file before modifying it. func DeleteProfile(ctx context.Context, profileName, configFilePath string) error { - configFile, err := loadOrCreateConfigFile(configFilePath) - if err != nil { - return err - } - - _, err = findMatchingProfile(configFile, func(s *ini.Section) bool { - return s.Name() == profileName - }) + configFile, err := config.LoadFile(configFilePath) if err != nil { - return fmt.Errorf("profile %q not found in %s", profileName, configFile.Path()) + return fmt.Errorf("cannot load config file %s: %w", configFilePath, err) } configFile.DeleteSection(profileName) @@ -174,19 +174,8 @@ func DeleteProfile(ctx context.Context, profileName, configFilePath string) erro section.Comment = defaultComment } - orig, backupErr := os.ReadFile(configFile.Path()) - if len(orig) > 0 && backupErr == nil { - log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) - err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) - if err != nil { - return fmt.Errorf("backup: %w", err) - } - log.Infof(ctx, "Overwriting %s", configFile.Path()) - } else if backupErr != nil { - log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", - configFile.Path(), backupErr) - } else { - log.Infof(ctx, "Saving %s", configFile.Path()) + if err := backupConfigFile(ctx, configFile); err != nil { + return err } return configFile.SaveTo(configFile.Path()) } From 5f039b916a8a1193bf021f42bff44e997715cd87 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 10:01:13 +0000 Subject: [PATCH 4/9] Consolidate logout tests Merge shared-host token deletion verification into one main parametrized test by addding the hostBasedKey and isSharedKey parameters to each case. This replaces the TestLogoutTokenCacheCleanup test with an assertion: host-based keys are preserved when another profile shares the same host, and deleted otherwise. --- cmd/auth/logout.go | 2 + cmd/auth/logout_test.go | 145 +++++++++++++++------------------------- 2 files changed, 57 insertions(+), 90 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 6827423aaa..1106ebbef1 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -116,6 +116,8 @@ func runLogout(ctx context.Context, args logoutArgs) error { } } + // First delete the profile and then perform best-effort token cache cleanup + // to avoid partial cleanup in case of errors from profile deletion. err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { return fmt.Errorf("failed to remove profile: %w", err) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 8003167116..4e046563c4 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -33,6 +33,18 @@ account_id = def456 experimental_is_unified_host = true ` +var logoutTestTokensCackeConfig = map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "shared-workspace-token"}, + "shared-workspace": {AccessToken: "shared-workspace-token"}, + "my-unique-workspace": {AccessToken: "my-unique-workspace-token"}, + "my-account": {AccessToken: "my-account-token"}, + "my-unified": {AccessToken: "my-unified-token"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "shared-workspace-host-token"}, + "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "unique-workspace-host-token"}, + "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-host-token"}, + "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-host-token"}, +} + func writeTempConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), ".databrickscfg") @@ -42,30 +54,55 @@ func writeTempConfig(t *testing.T, content string) string { func TestLogout(t *testing.T) { cases := []struct { - name string - profileName string - force bool - wantErr string + name string + profileName string + hostBasedKey string + isSharedKey bool + force bool + wantErr string }{ { - name: "existing profile with force", - profileName: "my-workspace", - force: true, + name: "existing workspace profile with shared host", + profileName: "my-workspace", + hostBasedKey: "https://my-workspace.cloud.databricks.com", + isSharedKey: true, + force: true, + }, + { + name: "existing workspace profile with unique host", + profileName: "my-unique-workspace", + hostBasedKey: "https://my-unique-workspace.cloud.databricks.com", + isSharedKey: false, + force: true, }, { - name: "existing profile without force in non-interactive mode", + name: "existing account profile", + profileName: "my-account", + hostBasedKey: "https://accounts.cloud.databricks.com/oidc/accounts/abc123", + isSharedKey: false, + force: true, + }, + { + name: "existing unified profile", + profileName: "my-unified", + hostBasedKey: "https://unified.cloud.databricks.com/oidc/accounts/def456", + isSharedKey: false, + force: true, + }, + { + name: "existing workspace profile without force in non-interactive mode", profileName: "my-workspace", force: false, wantErr: "please specify --force to skip confirmation in non-interactive mode", }, { - name: "non-existing profile with force", + name: "non-existing workspace profile with force", profileName: "nonexistent", force: true, wantErr: `profile "nonexistent" not found`, }, { - name: "non-existing profile without force", + name: "non-existing workspace profile without force", profileName: "nonexistent", force: false, wantErr: `profile "nonexistent" not found`, @@ -79,10 +116,7 @@ func TestLogout(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", configPath) tokenCache := &inMemoryTokenCache{ - Tokens: map[string]*oauth2.Token{ - "my-workspace": {AccessToken: "token1"}, - "https://my-workspace.cloud.databricks.com": {AccessToken: "token1"}, - }, + Tokens: logoutTestTokensCackeConfig, } err := runLogout(ctx, logoutArgs{ @@ -92,6 +126,7 @@ func TestLogout(t *testing.T) { tokenCache: tokenCache, configFilePath: configPath, }) + if tc.wantErr != "" { assert.ErrorContains(t, err, tc.wantErr) return @@ -101,84 +136,14 @@ func TestLogout(t *testing.T) { // Verify profile was removed from config. profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(tc.profileName)) require.NoError(t, err) - assert.Empty(t, profiles) + assert.Empty(t, profiles, "expected profile %q to be removed", tc.profileName) // Verify tokens were cleaned up. - assert.Nil(t, tokenCache.Tokens[tc.profileName]) - }) - } -} - -func TestLogoutTokenCacheCleanup(t *testing.T) { - cases := []struct { - name string - profileName string - tokens map[string]*oauth2.Token - wantRemoved []string - wantPreserved []string - }{ - { - name: "workspace shared host preserves host-keyed token", - profileName: "my-workspace", - tokens: map[string]*oauth2.Token{ - "my-workspace": {AccessToken: "token1"}, - "shared-workspace": {AccessToken: "token2"}, - "https://my-workspace.cloud.databricks.com": {AccessToken: "host-token"}, - }, - wantRemoved: []string{"my-workspace"}, - wantPreserved: []string{"https://my-workspace.cloud.databricks.com", "shared-workspace"}, - }, - { - name: "workspace unique host clears host-keyed token", - profileName: "my-unique-workspace", - tokens: map[string]*oauth2.Token{ - "my-unique-workspace": {AccessToken: "token1"}, - "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "host-token"}, - }, - wantRemoved: []string{"my-unique-workspace", "https://my-unique-workspace.cloud.databricks.com"}, - }, - { - name: "account profile clears OIDC-keyed token", - profileName: "my-account", - tokens: map[string]*oauth2.Token{ - "my-account": {AccessToken: "token1"}, - "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-token"}, - }, - wantRemoved: []string{"my-account", "https://accounts.cloud.databricks.com/oidc/accounts/abc123"}, - }, - { - name: "unified profile clears OIDC-keyed token", - profileName: "my-unified", - tokens: map[string]*oauth2.Token{ - "my-unified": {AccessToken: "token1"}, - "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-token"}, - }, - wantRemoved: []string{"my-unified", "https://unified.cloud.databricks.com/oidc/accounts/def456"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - configPath := writeTempConfig(t, logoutTestConfig) - t.Setenv("DATABRICKS_CONFIG_FILE", configPath) - - tokenCache := &inMemoryTokenCache{Tokens: tc.tokens} - - err := runLogout(ctx, logoutArgs{ - profileName: tc.profileName, - force: true, - profiler: profile.DefaultProfiler, - tokenCache: tokenCache, - configFilePath: configPath, - }) - require.NoError(t, err) - - for _, key := range tc.wantRemoved { - assert.Nil(t, tokenCache.Tokens[key], "expected token %q to be removed", key) - } - for _, key := range tc.wantPreserved { - assert.NotNil(t, tokenCache.Tokens[key], "expected token %q to be preserved", key) + assert.Nil(t, tokenCache.Tokens[tc.profileName], "expected token %q to be removed", tc.profileName) + if tc.isSharedKey { + assert.NotNil(t, tokenCache.Tokens[tc.hostBasedKey], "expected token %q to be preserved", tc.hostBasedKey) + } else { + assert.Nil(t, tokenCache.Tokens[tc.hostBasedKey], "expected token %q to be removed", tc.hostBasedKey) } }) } From fdfe72047ff1e25a18d8b9d9a14f43615501c240 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 12:11:44 +0000 Subject: [PATCH 5/9] Fix and improve failing DeleteProfile tests Rewrite the test to use inline config seeds and explicit expected state. Add cases for deleting the last non-default profile, deleting a unified host profile with multiple keys, and deleting the DEFAULT section. --- libs/databrickscfg/ops_test.go | 99 +++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index bc75935fbe..7cd34deb96 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/databricks-sdk-go/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" ) func TestLoadOrCreate(t *testing.T) { @@ -280,64 +281,88 @@ func TestSaveToProfile_MergeSemantics(t *testing.T) { } func TestDeleteProfile(t *testing.T) { - seedConfig := `; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. -[DEFAULT] + cfg := func(body string) string { + return "; " + defaultComment + "\n" + body + } + cases := []struct { + name string + seedConfig string + profileToDelete string + wantSections []string + wantDefaultKeys map[string]string + }{ + { + name: "delete one of two profiles", + seedConfig: cfg(`[DEFAULT] [first] host = https://first.cloud.databricks.com - [second] host = https://second.cloud.databricks.com -` - - cases := []struct { - name string - profileToDelete string - configFilePath string - wantErr string - wantRemainingSectionCnt int - }{ +`), + profileToDelete: "first", + wantSections: []string{"DEFAULT", "second"}, + }, { - name: "delete existing profile", - profileToDelete: "first", - wantRemainingSectionCnt: 2, // DEFAULT + second + name: "delete last non-default profile", + seedConfig: cfg(`[DEFAULT] +host = https://default.cloud.databricks.com +[only] +host = https://only.cloud.databricks.com +`), + profileToDelete: "only", + wantSections: []string{"DEFAULT"}, + wantDefaultKeys: map[string]string{"host": "https://default.cloud.databricks.com"}, }, { - name: "profile not found", - profileToDelete: "nonexistent", - wantErr: `profile "nonexistent" not found`, + name: "delete profile with multiple keys", + seedConfig: cfg(`[DEFAULT] +[simple] +host = https://simple.cloud.databricks.com +[my-unified] +host = https://unified.cloud.databricks.com +account_id = def456 +experimental_is_unified_host = true +`), + profileToDelete: "my-unified", + wantSections: []string{"DEFAULT", "simple"}, }, { - name: "custom config path", - profileToDelete: "second", - configFilePath: "custom", - wantRemainingSectionCnt: 2, // DEFAULT + first + name: "delete default clears its keys and restores comment", + seedConfig: cfg(`[DEFAULT] +host = https://default.cloud.databricks.com +[only] +host = https://only.cloud.databricks.com +`), + profileToDelete: "DEFAULT", + wantSections: []string{"DEFAULT", "only"}, + wantDefaultKeys: map[string]string{}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() - dir := t.TempDir() - - filename := ".databrickscfg" - if tc.configFilePath != "" { - filename = tc.configFilePath - } - path := filepath.Join(dir, filename) - require.NoError(t, os.WriteFile(path, []byte(seedConfig), fileMode)) + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(tc.seedConfig), fileMode)) err := DeleteProfile(ctx, tc.profileToDelete, path) - if tc.wantErr != "" { - assert.ErrorContains(t, err, tc.wantErr) - return - } require.NoError(t, err) - file, err := loadOrCreateConfigFile(path) + file, err := config.LoadFile(path) require.NoError(t, err) - assert.Len(t, file.Sections(), tc.wantRemainingSectionCnt) - assert.False(t, file.HasSection(tc.profileToDelete)) + + var sectionNames []string + for _, s := range file.Sections() { + sectionNames = append(sectionNames, s.Name()) + } + assert.Equal(t, tc.wantSections, sectionNames) + + defaultSection := file.Section(ini.DefaultSection) + assert.Contains(t, defaultSection.Comment, defaultComment) + if tc.wantDefaultKeys != nil { + assert.Equal(t, tc.wantDefaultKeys, defaultSection.KeysHash()) + } }) } } From 5ccb7f57be7ab55cbf199dd67d0769f2668cf675 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 12:44:57 +0000 Subject: [PATCH 6/9] Fix test variable typo --- cmd/auth/logout_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 4e046563c4..8c86295e10 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -33,7 +33,7 @@ account_id = def456 experimental_is_unified_host = true ` -var logoutTestTokensCackeConfig = map[string]*oauth2.Token{ +var logoutTestTokensCacheConfig = map[string]*oauth2.Token{ "my-workspace": {AccessToken: "shared-workspace-token"}, "shared-workspace": {AccessToken: "shared-workspace-token"}, "my-unique-workspace": {AccessToken: "my-unique-workspace-token"}, @@ -116,7 +116,7 @@ func TestLogout(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", configPath) tokenCache := &inMemoryTokenCache{ - Tokens: logoutTestTokensCackeConfig, + Tokens: logoutTestTokensCacheConfig, } err := runLogout(ctx, logoutArgs{ From 0ca6e59085d63557519e48e026af7ca440319e86 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 13:27:28 +0000 Subject: [PATCH 7/9] Removed redundant test case from logout --- cmd/auth/logout_test.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 8c86295e10..4d447da410 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -96,13 +96,7 @@ func TestLogout(t *testing.T) { wantErr: "please specify --force to skip confirmation in non-interactive mode", }, { - name: "non-existing workspace profile with force", - profileName: "nonexistent", - force: true, - wantErr: `profile "nonexistent" not found`, - }, - { - name: "non-existing workspace profile without force", + name: "non-existing workspace profile", profileName: "nonexistent", force: false, wantErr: `profile "nonexistent" not found`, From e1a652daccea124aab9fe66a5a7bf6ae69174345 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 14:49:10 +0000 Subject: [PATCH 8/9] Add interactive profile picker to auth logout When --profile is not specified in an interactive terminal, show a searchable prompt listing all configured profiles. Profiles are sorted alphabetically and displayed with their host or account ID. The picker supports fuzzy search by name, host, or account ID. --- cmd/auth/logout.go | 65 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 1106ebbef1..778c310cbd 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -56,7 +56,11 @@ logging out.`, defaultConfigPath), if !cmdio.IsPromptSupported(ctx) { return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile") } - return errors.New("please specify a profile to log out of using --profile") + selected, err := promptForLogoutProfile(ctx, profile.DefaultProfiler) + if err != nil { + return err + } + profileName = selected } tokenCache, err := cache.NewFileTokenCache() @@ -149,6 +153,65 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil return &profiles[0], nil } +type logoutProfileItem struct { + PaddedName string + profile.Profile +} + +// promptForLogoutProfile shows an interactive profile picker for logout. +// Account profiles are displayed as "name (account: id)", workspace profiles +// as "name (host)". +func promptForLogoutProfile(ctx context.Context, profiler profile.Profiler) (string, error) { + allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return "", err + } + if len(allProfiles) == 0 { + return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") + } + + slices.SortFunc(allProfiles, func(a, b profile.Profile) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + + maxNameLen := 0 + for _, p := range allProfiles { + maxNameLen = max(maxNameLen, len(p.Name)) + } + + items := make([]logoutProfileItem, len(allProfiles)) + for i, p := range allProfiles { + items[i] = logoutProfileItem{ + PaddedName: fmt.Sprintf("%-*s", maxNameLen, p.Name), + Profile: p, + } + } + + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + Label: "Select a profile to log out of", + Items: items, + StartInSearchMode: len(items) > 5, + // Allow searching by name, host, and account ID. + Searcher: func(input string, index int) bool { + input = strings.ToLower(input) + name := strings.ToLower(items[index].Name) + host := strings.ToLower(items[index].Host) + accountID := strings.ToLower(items[index].AccountID) + return strings.Contains(name, input) || strings.Contains(host, input) || strings.Contains(accountID, input) + }, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | faint }}", + Active: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, + Inactive: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, + Selected: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, + }, + }) + if err != nil { + return "", err + } + return items[i].Name, nil +} + // clearTokenCache removes cached OAuth tokens for the given profile from the // token cache. It removes: // 1. The entry keyed by the profile name. From 67eff3f5ff4dc2f8e5c5564a9af9084fe17e7417 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 14:50:10 +0000 Subject: [PATCH 9/9] Expand auth logout long description with usage details Document the four interaction modes (explicit profile, interactive picker, non-interactive error, and --force) in the command's long help text. --- cmd/auth/logout.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 778c310cbd..d3b56c576f 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "runtime" + "slices" "strings" "github.com/databricks/cli/libs/cmdio" @@ -13,6 +14,7 @@ import ( "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" + "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -41,7 +43,33 @@ This command removes the specified profile from %s and deletes any associated cached OAuth tokens. You will need to run "databricks auth login" to re-authenticate after -logging out.`, defaultConfigPath), +logging out. + +This command requires a profile to be specified (using --profile). If you +omit --profile and run in an interactive terminal, you'll be shown an +interactive profile picker to select which profile to log out of. + +While this command always removes the specified profile, the runtime behaviour +depends on whether you run it in an interactive terminal and which flags you +provide. + +1. If you specify --profile, the command will log out of that profile. + In an interactive terminal, you'll be asked to confirm unless --force + is specified. + +2. If you omit --profile and run in an interactive terminal, you'll be shown + an interactive picker listing all profiles from your configuration file. + Profiles are sorted alphabetically by name. You can search by profile + name, host, or account ID. After selecting a profile, you'll be asked to + confirm unless --force is specified. + +3. If you omit --profile and run in a non-interactive environment (e.g. + CI/CD pipelines), the command will fail with an error asking you to + specify --profile. + +4. Use --force to skip the confirmation prompt. This is required when + running in non-interactive mode; otherwise the command will fail.`, + defaultConfigPath), } var force bool