From e86dbe1951c8eef6ffd64782193f4032dfe6ea0b Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 8 May 2026 11:43:19 +0200 Subject: [PATCH 1/3] auth: highlight default profile and unify pickers across login/logout/switch/token The interactive profile pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login` now mark the default profile with a "[default]" tag and move it to the top of the list. The pickers used by login/token are identical (with "Create a new profile" and "Enter a host URL manually" entries), and the pickers used by switch/logout share a common implementation. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 + cmd/auth/login.go | 33 ++++++ cmd/auth/logout.go | 15 +-- cmd/auth/profile_picker.go | 125 ++++++++++++++++++++++ cmd/auth/profile_picker_test.go | 80 ++++++++++++++ cmd/auth/switch.go | 51 +++------ cmd/auth/token.go | 70 ++---------- libs/databrickscfg/profile/select.go | 74 +++++++++---- libs/databrickscfg/profile/select_test.go | 76 +++++++++++++ 9 files changed, 399 insertions(+), 127 deletions(-) create mode 100644 cmd/auth/profile_picker.go create mode 100644 cmd/auth/profile_picker_test.go create mode 100644 libs/databrickscfg/profile/select_test.go diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 409a875ad1..08048c453b 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,6 +4,8 @@ ### CLI +* Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively. + ### Bundles * Fixed `--force-pull` on `bundle summary` and `bundle open` so the flag bypasses the local state cache and reads state from the workspace. diff --git a/cmd/auth/login.go b/cmd/auth/login.go index c4d0851011..bd6d3ca441 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -180,6 +180,39 @@ a new profile is created. } } + // When interactive and nothing was specified, show a picker that lets + // the user re-login to an existing profile, create a new one, or enter + // a host URL. + if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { + allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { + return err + } + if len(allProfiles) > 0 { + currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) + result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ + Label: "Select a profile", + Default: currentDefault, + IncludeExtras: true, + }) + if err != nil { + return err + } + switch result { + case profilePickerProfile: + profileName = selected + case profilePickerEnterHost: + host, err := promptForHost(ctx) + if err != nil { + return err + } + authArguments.Host = host + case profilePickerCreateNew: + // Fall through to the profile name prompt below. + } + } + } + // If the user has not specified a profile name, prompt for one. if profileName == "" { var err error diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index a8cd14be0a..2f8e2c1ba2 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -119,17 +119,18 @@ to specify it explicitly. if err != nil { return err } - selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ - Label: "Select a profile to log out of", - Profiles: allProfiles, - StartInSearchMode: len(allProfiles) > 5, - ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, - InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, - SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, + currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) + result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ + Label: "Select a profile to log out of", + Default: currentDefault, }) if err != nil { return err } + // Without IncludeExtras, the picker only returns profile selections. + if result != profilePickerProfile { + return fmt.Errorf("unexpected picker result: %v", result) + } profileName = selected } diff --git a/cmd/auth/profile_picker.go b/cmd/auth/profile_picker.go new file mode 100644 index 0000000000..8673e98856 --- /dev/null +++ b/cmd/auth/profile_picker.go @@ -0,0 +1,125 @@ +package auth + +import ( + "context" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" +) + +// profilePickerResult represents the user's choice from pickAuthProfile. +type profilePickerResult int + +const ( + profilePickerProfile profilePickerResult = iota // an existing profile was picked + profilePickerCreateNew // user chose "Create a new profile" + profilePickerEnterHost // user chose "Enter a host URL manually" +) + +const ( + profilePickerCreateNewLabel = "Create a new profile" + profilePickerEnterHostLabel = "Enter a host URL manually" +) + +// profilePickerOptions configures pickAuthProfile. +type profilePickerOptions struct { + // Label shown above the picker. + Label string + + // Default is the name of the default profile. When set, it is moved to the + // top of the list and decorated with "[default]". + Default string + + // IncludeExtras appends "Create a new profile" and "Enter a host URL + // manually" entries after the profile list. + IncludeExtras bool +} + +// pickerItem is a single entry rendered by the picker. It can be either a real +// profile or one of the extra action entries (Create new / Enter host). +type pickerItem struct { + Name string + Host string + AccountID string + IsDefault bool + + // IsExtra distinguishes action entries (Create new / Enter host) from + // real profiles, so a profile that happens to share a label name still + // resolves correctly. + IsExtra bool + Extra profilePickerResult +} + +// buildPickerItems returns the items shown by pickAuthProfile, with the default +// profile moved to the top and the extras appended (when requested). +func buildPickerItems(profiles profile.Profiles, defaultName string, includeExtras bool) []pickerItem { + defaultIdx := -1 + if defaultName != "" { + for i, p := range profiles { + if p.Name == defaultName { + defaultIdx = i + break + } + } + } + + itemFor := func(p profile.Profile, isDefault bool) pickerItem { + return pickerItem{ + Name: p.Name, + Host: p.Host, + AccountID: p.AccountID, + IsDefault: isDefault, + } + } + + items := make([]pickerItem, 0, len(profiles)+2) + if defaultIdx >= 0 { + items = append(items, itemFor(profiles[defaultIdx], true)) + } + for i, p := range profiles { + if i == defaultIdx { + continue + } + items = append(items, itemFor(p, false)) + } + if includeExtras { + items = append(items, + pickerItem{Name: profilePickerCreateNewLabel, IsExtra: true, Extra: profilePickerCreateNew}, + pickerItem{Name: profilePickerEnterHostLabel, IsExtra: true, Extra: profilePickerEnterHost}, + ) + } + return items +} + +// pickAuthProfile shows the auth profile picker and returns the user's choice. +// When the result is profilePickerProfile, the second return value is the +// selected profile name. For the other results it is empty. +func pickAuthProfile(ctx context.Context, profiles profile.Profiles, opts profilePickerOptions) (profilePickerResult, string, error) { + items := buildPickerItems(profiles, opts.Default, opts.IncludeExtras) + + idx, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ + Label: opts.Label, + Items: items, + StartInSearchMode: len(profiles) > 5, + Searcher: func(input string, index int) bool { + input = strings.ToLower(input) + return strings.Contains(strings.ToLower(items[index].Name), input) || + strings.Contains(strings.ToLower(items[index].Host), input) || + strings.Contains(strings.ToLower(items[index].AccountID), input) + }, + LabelTemplate: "{{ . | faint }}", + Active: `▸ {{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`, + Inactive: ` {{.Name}}{{if .IsDefault}} [default]{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`, + Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + }) + if err != nil { + return 0, "", err + } + + picked := items[idx] + if picked.IsExtra { + return picked.Extra, "", nil + } + return profilePickerProfile, picked.Name, nil +} diff --git a/cmd/auth/profile_picker_test.go b/cmd/auth/profile_picker_test.go new file mode 100644 index 0000000000..b83ee555a3 --- /dev/null +++ b/cmd/auth/profile_picker_test.go @@ -0,0 +1,80 @@ +package auth + +import ( + "testing" + + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/stretchr/testify/assert" +) + +func TestBuildPickerItems(t *testing.T) { + profiles := profile.Profiles{ + {Name: "alpha", Host: "https://alpha.cloud.databricks.example"}, + {Name: "bravo", Host: "https://bravo.cloud.databricks.example"}, + {Name: "charlie", Host: "https://charlie.cloud.databricks.example"}, + } + + cases := []struct { + name string + defaultName string + includeExtras bool + wantNames []string + wantDefault string + wantExtras []profilePickerResult + }{ + { + name: "no default no extras", + wantNames: []string{"alpha", "bravo", "charlie"}, + wantDefault: "", + }, + { + name: "default moves to top", + defaultName: "bravo", + wantNames: []string{"bravo", "alpha", "charlie"}, + wantDefault: "bravo", + }, + { + name: "extras appended after profiles", + includeExtras: true, + wantNames: []string{"alpha", "bravo", "charlie", profilePickerCreateNewLabel, profilePickerEnterHostLabel}, + wantExtras: []profilePickerResult{profilePickerCreateNew, profilePickerEnterHost}, + }, + { + name: "default first, then extras at the bottom", + defaultName: "charlie", + includeExtras: true, + wantNames: []string{"charlie", "alpha", "bravo", profilePickerCreateNewLabel, profilePickerEnterHostLabel}, + wantDefault: "charlie", + wantExtras: []profilePickerResult{profilePickerCreateNew, profilePickerEnterHost}, + }, + { + name: "default not in profiles is ignored", + defaultName: "missing", + wantNames: []string{"alpha", "bravo", "charlie"}, + wantDefault: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + items := buildPickerItems(profiles, tc.defaultName, tc.includeExtras) + + gotNames := make([]string, len(items)) + gotDefault := "" + var gotExtras []profilePickerResult + for i, it := range items { + gotNames[i] = it.Name + if it.IsDefault { + assert.Empty(t, gotDefault) + gotDefault = it.Name + } + if it.IsExtra { + gotExtras = append(gotExtras, it.Extra) + } + } + assert.Equal(t, tc.wantNames, gotNames) + assert.Equal(t, tc.wantDefault, gotDefault) + assert.Equal(t, tc.wantExtras, gotExtras) + }) + } +} diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 2ff7dfad1a..46504f802a 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -1,10 +1,8 @@ package auth import ( - "context" "errors" "fmt" - "strings" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" @@ -45,11 +43,22 @@ to see which profile is currently the default.`, } currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, configFile) - selectedName, err := promptForSwitchProfile(ctx, allProfiles, currentDefault) + label := "Select a profile to set as default" + if currentDefault != "" { + label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault) + } + result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ + Label: label, + Default: currentDefault, + }) if err != nil { return err } - profileName = selectedName + // Without IncludeExtras, the picker only returns profile selections. + if result != profilePickerProfile { + return fmt.Errorf("unexpected picker result: %v", result) + } + profileName = selected } else { // Validate the profile exists. profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(profileName)) @@ -72,37 +81,3 @@ to see which profile is currently the default.`, return cmd } - -// promptForSwitchProfile shows an interactive profile picker for the switch command. -// Reuses profileSelectItem from token.go for consistent display. -func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles, currentDefault string) (string, error) { - items := make([]profileSelectItem, 0, len(profiles)) - for _, p := range profiles { - items = append(items, profileSelectItem{Name: p.Name, Host: p.Host}) - } - - label := "Select a profile to set as default" - if currentDefault != "" { - label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault) - } - - i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ - Label: label, - Items: items, - StartInSearchMode: len(profiles) > 5, - Searcher: func(input string, index int) bool { - input = strings.ToLower(input) - name := strings.ToLower(items[index].Name) - host := strings.ToLower(items[index].Host) - return strings.Contains(name, input) || strings.Contains(host, input) - }, - LabelTemplate: "{{ . | faint }}", - Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`, - Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`, - Selected: `{{ "Default profile" | faint }}: {{ .Name | bold }}`, - }) - if err != nil { - return "", err - } - return profiles[i].Name, nil -} diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 67dc56807c..8c439f6e31 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -30,16 +30,6 @@ func helpfulError(ctx context.Context, profile string, persistentAuth u2m.OAuthA return fmt.Sprintf("Try logging in again with `%s` before retrying. If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new", loginMsg) } -// profileSelectionResult represents the user's choice from the interactive -// profile picker. -type profileSelectionResult int - -const ( - profileSelected profileSelectionResult = iota // User picked a profile - enterHostSelected // User chose "Enter a host URL manually" - createNewSelected // User chose "Create a new profile" -) - func newTokenCommand(authArguments *auth.AuthArguments) *cobra.Command { cmd := &cobra.Command{ Use: "token [PROFILE]", @@ -352,15 +342,20 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs } // Interactive: show profile picker. - result, selectedName, err := promptForProfileSelection(ctx, allProfiles) + currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) + result, selectedName, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ + Label: "Select a profile", + Default: currentDefault, + IncludeExtras: true, + }) if err != nil { return "", nil, err } switch result { - case enterHostSelected: + case profilePickerEnterHost: // Fall through — setHostAndAccountId will prompt for the host. return "", nil, nil - case createNewSelected: + case profilePickerCreateNew: return runInlineLogin(ctx, profiler, tokenCache, mode) default: p, err := loadProfileByName(ctx, selectedName, profiler) @@ -371,55 +366,6 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs } } -// profileSelectItem is used by promptForProfileSelection to render both -// regular profiles and special action options in the same select list. -type profileSelectItem struct { - Name string - Host string -} - -// promptForProfileSelection shows a select list with all configured profiles -// plus "Enter a host URL" and "Create a new profile" options. -// Returns the selection type and, when a profile is selected, its name. -func promptForProfileSelection(ctx context.Context, profiles profile.Profiles) (profileSelectionResult, string, error) { - items := make([]profileSelectItem, 0, len(profiles)+2) - for _, p := range profiles { - items = append(items, profileSelectItem{Name: p.Name, Host: p.Host}) - } - createProfileIdx := len(items) - items = append(items, profileSelectItem{Name: "Create a new profile"}) - enterHostIdx := len(items) - items = append(items, profileSelectItem{Name: "Enter a host URL manually"}) - - i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ - Label: "Select a profile", - Items: items, - StartInSearchMode: len(profiles) > 5, - Searcher: func(input string, index int) bool { - input = strings.ToLower(input) - name := strings.ToLower(items[index].Name) - host := strings.ToLower(items[index].Host) - return strings.Contains(name, input) || strings.Contains(host, input) - }, - LabelTemplate: "{{ . | faint }}", - Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`, - Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`, - Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, - }) - if err != nil { - return 0, "", err - } - - switch i { - case enterHostIdx: - return enterHostSelected, "", nil - case createProfileIdx: - return createNewSelected, "", nil - default: - return profileSelected, profiles[i].Name, nil - } -} - // runInlineLogin runs a minimal interactive login flow: prompts for a profile // name and host, performs the OAuth challenge, saves the profile to // .databrickscfg, and returns the new profile name and profile. diff --git a/libs/databrickscfg/profile/select.go b/libs/databrickscfg/profile/select.go index d0470ef58f..63d5207db5 100644 --- a/libs/databrickscfg/profile/select.go +++ b/libs/databrickscfg/profile/select.go @@ -10,8 +10,8 @@ import ( ) var ( - defaultActiveTemplate = `{{.Name | bold}} ({{.Host|faint}})` - defaultInactiveTemplate = `{{.Name}}` + defaultActiveTemplate = `{{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}} ({{.Host|faint}})` + defaultInactiveTemplate = `{{.Name}}{{if .IsDefault}} [default]{{end}}` defaultSelectedTemplate = "{{ \"Using profile\" | faint }}: {{ .Name | bold }}" ) @@ -25,13 +25,20 @@ type SelectConfig struct { StartInSearchMode bool + // Default is the name of the default profile. When non-empty and matching a + // profile in Profiles, that profile is moved to the top of the list and + // rendered with IsDefault=true so templates can decorate it (e.g. with a + // "[default]" tag). + Default string + // Go template strings for rendering items. Templates have access to all - // [Profile] fields, a Cloud method, and a PaddedName field that is the - // profile name right-padded to align with the longest name in the list. + // [Profile] fields, a Cloud method, a PaddedName field that is the profile + // name right-padded to align with the longest name in the list, and an + // IsDefault boolean that is true for the entry matching SelectConfig.Default. // // Defaults: - // Active: `{{.Name | bold}} ({{.Host|faint}})` - // Inactive: `{{.Name}}` + // Active: `{{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}} ({{.Host|faint}})` + // Inactive: `{{.Name}}{{if .IsDefault}} [default]{{end}}` // Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}` ActiveTemplate string InactiveTemplate string @@ -42,29 +49,56 @@ type SelectConfig struct { type selectItem struct { Profile PaddedName string + IsDefault bool } -// SelectProfile shows an interactive profile picker and returns the name of the -// selected profile. -func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { - if len(cfg.Profiles) == 0 { - return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") - } - +// buildSelectItems returns the list of items to render, with the default profile +// moved to the top and tagged with IsDefault=true. The relative order of the +// other profiles is preserved. +func buildSelectItems(profiles Profiles, defaultName string) []selectItem { maxNameLen := 0 - for _, p := range cfg.Profiles { - if len(p.Name) > maxNameLen { - maxNameLen = len(p.Name) - } + for _, p := range profiles { + maxNameLen = max(maxNameLen, len(p.Name)) } - items := make([]selectItem, len(cfg.Profiles)) - for i, p := range cfg.Profiles { - items[i] = selectItem{ + items := make([]selectItem, 0, len(profiles)) + itemFor := func(p Profile) selectItem { + return selectItem{ Profile: p, PaddedName: fmt.Sprintf("%-*s", maxNameLen, p.Name), + IsDefault: defaultName != "" && p.Name == defaultName, + } + } + + defaultIdx := -1 + if defaultName != "" { + for i, p := range profiles { + if p.Name == defaultName { + defaultIdx = i + break + } } } + if defaultIdx >= 0 { + items = append(items, itemFor(profiles[defaultIdx])) + } + for i, p := range profiles { + if i == defaultIdx { + continue + } + items = append(items, itemFor(p)) + } + return items +} + +// SelectProfile shows an interactive profile picker and returns the name of the +// selected profile. +func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { + if len(cfg.Profiles) == 0 { + return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") + } + + items := buildSelectItems(cfg.Profiles, cfg.Default) if cfg.ActiveTemplate == "" { cfg.ActiveTemplate = defaultActiveTemplate diff --git a/libs/databrickscfg/profile/select_test.go b/libs/databrickscfg/profile/select_test.go new file mode 100644 index 0000000000..90ba3dc4cd --- /dev/null +++ b/libs/databrickscfg/profile/select_test.go @@ -0,0 +1,76 @@ +package profile + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildSelectItems(t *testing.T) { + profiles := Profiles{ + {Name: "alpha", Host: "https://alpha.cloud.databricks.example"}, + {Name: "bravo", Host: "https://bravo.cloud.databricks.example"}, + {Name: "charlie", Host: "https://charlie.cloud.databricks.example"}, + } + + cases := []struct { + name string + defaultName string + wantOrder []string + wantDefault string + }{ + { + name: "no default preserves order", + defaultName: "", + wantOrder: []string{"alpha", "bravo", "charlie"}, + wantDefault: "", + }, + { + name: "default in the middle moves to the top", + defaultName: "bravo", + wantOrder: []string{"bravo", "alpha", "charlie"}, + wantDefault: "bravo", + }, + { + name: "default already first stays first", + defaultName: "alpha", + wantOrder: []string{"alpha", "bravo", "charlie"}, + wantDefault: "alpha", + }, + { + name: "default not in profiles is ignored", + defaultName: "missing", + wantOrder: []string{"alpha", "bravo", "charlie"}, + wantDefault: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + items := buildSelectItems(profiles, tc.defaultName) + gotOrder := make([]string, len(items)) + gotDefault := "" + for i, it := range items { + gotOrder[i] = it.Name + if it.IsDefault { + assert.Empty(t, gotDefault, "more than one item flagged as default") + gotDefault = it.Name + } + } + assert.Equal(t, tc.wantOrder, gotOrder) + assert.Equal(t, tc.wantDefault, gotDefault) + }) + } +} + +func TestBuildSelectItems_PaddedName(t *testing.T) { + profiles := Profiles{ + {Name: "a"}, + {Name: "looooong"}, + {Name: "med"}, + } + items := buildSelectItems(profiles, "") + for _, it := range items { + assert.Len(t, it.PaddedName, len("looooong")) + } +} From 1580065aa2987d0af9361dab20c5fad5da670191 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 8 May 2026 12:32:29 +0200 Subject: [PATCH 2/3] auth: per-command Selected wording and show login picker with empty config - pickAuthProfile gains a SelectedNoun option so logout prints "Selected profile" and switch prints "Default profile" while token and login keep "Using profile". - auth login now shows the picker even when no profiles are configured, so a first-time user can pick web-based discovery (Create a new profile) or a manual host URL from the start. The label adapts to "How would you like to log in?" in the empty-config case. Co-authored-by: Isaac --- cmd/auth/login.go | 44 ++++++++++++++++++--------------- cmd/auth/logout.go | 5 ++-- cmd/auth/profile_picker.go | 17 +++++++++++-- cmd/auth/profile_picker_test.go | 19 +++++++++++--- cmd/auth/switch.go | 5 ++-- 5 files changed, 60 insertions(+), 30 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index bd6d3ca441..03cd9859f8 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -182,34 +182,38 @@ a new profile is created. // When interactive and nothing was specified, show a picker that lets // the user re-login to an existing profile, create a new one, or enter - // a host URL. + // a host URL. With no profiles configured the picker still shows the + // two action entries so the user can choose between web-based discovery + // (Create a new profile) and a manual host URL. if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { return err } - if len(allProfiles) > 0 { - currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) - result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ - Label: "Select a profile", - Default: currentDefault, - IncludeExtras: true, - }) + label := "Select a profile" + if len(allProfiles) == 0 { + label = "How would you like to log in?" + } + currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) + result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ + Label: label, + Default: currentDefault, + IncludeExtras: true, + }) + if err != nil { + return err + } + switch result { + case profilePickerProfile: + profileName = selected + case profilePickerEnterHost: + host, err := promptForHost(ctx) if err != nil { return err } - switch result { - case profilePickerProfile: - profileName = selected - case profilePickerEnterHost: - host, err := promptForHost(ctx) - if err != nil { - return err - } - authArguments.Host = host - case profilePickerCreateNew: - // Fall through to the profile name prompt below. - } + authArguments.Host = host + case profilePickerCreateNew: + // Fall through to the profile name prompt below. } } diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 2f8e2c1ba2..cba94fd275 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -121,8 +121,9 @@ to specify it explicitly. } currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ - Label: "Select a profile to log out of", - Default: currentDefault, + Label: "Select a profile to log out of", + SelectedNoun: "Selected profile", + Default: currentDefault, }) if err != nil { return err diff --git a/cmd/auth/profile_picker.go b/cmd/auth/profile_picker.go index 8673e98856..01279171ac 100644 --- a/cmd/auth/profile_picker.go +++ b/cmd/auth/profile_picker.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "strings" "github.com/databricks/cli/libs/cmdio" @@ -27,12 +28,17 @@ type profilePickerOptions struct { // Label shown above the picker. Label string + // SelectedNoun is the noun shown after selection ("Using profile", + // "Selected profile", "Default profile"). Defaults to "Using profile". + SelectedNoun string + // Default is the name of the default profile. When set, it is moved to the // top of the list and decorated with "[default]". Default string // IncludeExtras appends "Create a new profile" and "Enter a host URL - // manually" entries after the profile list. + // manually" entries after the profile list. Picker action entries are + // shown even when the profile list is empty. IncludeExtras bool } @@ -97,6 +103,13 @@ func buildPickerItems(profiles profile.Profiles, defaultName string, includeExtr // selected profile name. For the other results it is empty. func pickAuthProfile(ctx context.Context, profiles profile.Profiles, opts profilePickerOptions) (profilePickerResult, string, error) { items := buildPickerItems(profiles, opts.Default, opts.IncludeExtras) + if len(items) == 0 { + return 0, "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") + } + noun := opts.SelectedNoun + if noun == "" { + noun = "Using profile" + } idx, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ Label: opts.Label, @@ -111,7 +124,7 @@ func pickAuthProfile(ctx context.Context, profiles profile.Profiles, opts profil LabelTemplate: "{{ . | faint }}", Active: `▸ {{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`, Inactive: ` {{.Name}}{{if .IsDefault}} [default]{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`, - Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + Selected: `{{ "` + noun + `" | faint }}: {{ .Name | bold }}`, }) if err != nil { return 0, "", err diff --git a/cmd/auth/profile_picker_test.go b/cmd/auth/profile_picker_test.go index b83ee555a3..dba2f989c8 100644 --- a/cmd/auth/profile_picker_test.go +++ b/cmd/auth/profile_picker_test.go @@ -55,15 +55,18 @@ func TestBuildPickerItems(t *testing.T) { }, } + t.Run("empty profiles with extras shows only extras", func(t *testing.T) { + items := buildPickerItems(profile.Profiles{}, "", true) + assert.Equal(t, []string{profilePickerCreateNewLabel, profilePickerEnterHostLabel}, namesOf(items)) + }) + for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { items := buildPickerItems(profiles, tc.defaultName, tc.includeExtras) - gotNames := make([]string, len(items)) gotDefault := "" var gotExtras []profilePickerResult - for i, it := range items { - gotNames[i] = it.Name + for _, it := range items { if it.IsDefault { assert.Empty(t, gotDefault) gotDefault = it.Name @@ -72,9 +75,17 @@ func TestBuildPickerItems(t *testing.T) { gotExtras = append(gotExtras, it.Extra) } } - assert.Equal(t, tc.wantNames, gotNames) + assert.Equal(t, tc.wantNames, namesOf(items)) assert.Equal(t, tc.wantDefault, gotDefault) assert.Equal(t, tc.wantExtras, gotExtras) }) } } + +func namesOf(items []pickerItem) []string { + names := make([]string, len(items)) + for i, it := range items { + names[i] = it.Name + } + return names +} diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 46504f802a..c91894fe85 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -48,8 +48,9 @@ to see which profile is currently the default.`, label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault) } result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ - Label: label, - Default: currentDefault, + Label: label, + SelectedNoun: "Default profile", + Default: currentDefault, }) if err != nil { return err From 6759e40537dd322ed7ac62e7e489221be439e80a Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 8 May 2026 14:08:40 +0200 Subject: [PATCH 3/3] auth: start picker in search mode and visually distinguish action items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The auth profile picker now always starts in search mode, so typing immediately filters the list. The highlighted item is the top match, effectively suggesting which profile to pick. - Action entries (Create a new profile, Enter a host URL manually) are rendered as faint text with "+" / "→" prefixes and no host/account suffix so the eye reads "profiles above, actions below" without a literal separator. - Action entries are kept visible regardless of the search query so the user can always reach them, including when no profile matches. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + cmd/auth/profile_picker.go | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 72f74ffdf5..9028dabced 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -6,6 +6,7 @@ * `databricks auth describe` now reports where U2M (`databricks-cli`) tokens are stored: `plaintext` (`~/.databricks/token-cache.json`) or `secure` (OS keyring), and the source of the choice (env var, config setting, or default). * Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively. +* The interactive auth profile pickers now start in search mode so typing immediately filters the list, and the action entries (`+ Create a new profile`, `→ Enter a host URL manually`) are visually distinct from real profiles and stay visible regardless of the search query. ### Bundles diff --git a/cmd/auth/profile_picker.go b/cmd/auth/profile_picker.go index 01279171ac..79fc440c56 100644 --- a/cmd/auth/profile_picker.go +++ b/cmd/auth/profile_picker.go @@ -19,8 +19,8 @@ const ( ) const ( - profilePickerCreateNewLabel = "Create a new profile" - profilePickerEnterHostLabel = "Enter a host URL manually" + profilePickerCreateNewLabel = "+ Create a new profile" + profilePickerEnterHostLabel = "→ Enter a host URL manually" ) // profilePickerOptions configures pickAuthProfile. @@ -114,16 +114,22 @@ func pickAuthProfile(ctx context.Context, profiles profile.Profiles, opts profil idx, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ Label: opts.Label, Items: items, - StartInSearchMode: len(profiles) > 5, + StartInSearchMode: true, Searcher: func(input string, index int) bool { + // Action entries (Create new / Enter host) stay visible regardless + // of the search query so the user can always reach them, including + // when the typed query doesn't match any profile. + if items[index].IsExtra { + return true + } input = strings.ToLower(input) return strings.Contains(strings.ToLower(items[index].Name), input) || strings.Contains(strings.ToLower(items[index].Host), input) || strings.Contains(strings.ToLower(items[index].AccountID), input) }, LabelTemplate: "{{ . | faint }}", - Active: `▸ {{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`, - Inactive: ` {{.Name}}{{if .IsDefault}} [default]{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`, + Active: `▸ {{if .IsExtra}}{{.Name | faint | bold}}{{else}}{{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}{{end}}`, + Inactive: ` {{if .IsExtra}}{{.Name | faint}}{{else}}{{.Name}}{{if .IsDefault}} [default]{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}{{end}}`, Selected: `{{ "` + noun + `" | faint }}: {{ .Name | bold }}`, }) if err != nil {