Skip to content

Commit 83efe60

Browse files
authored
auth: highlight default profile and unify pickers across login/logout/switch/token (#5218)
## Why When users have several profiles in `~/.databrickscfg`, the picker shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login` doesn't tell them which one is currently the default. Locating the default in a long list, especially when commands try to pre-fill it, is more guesswork than it should be. The four commands also drift in their picker implementations: `auth token` has a richer picker that lets users create a new profile, while `auth login` only has a text prompt for the profile name, and `auth logout` and `auth switch` have their own slightly different flavors. ## Changes **Before:** Each auth command had its own picker. None highlighted the default profile. `auth login` couldn't pick an existing profile from a list. **Now:** The four auth commands share two picker shapes: - `auth switch` and `auth logout` use a profile-only picker - `auth token` and `auth login` use the same picker plus "Create a new profile" and "Enter a host URL manually" entries In all four, the default profile (from `[__settings__]` / `default_profile`) is moved to the top of the list and tagged `[default]` (green when highlighted). Implementation: - `libs/databrickscfg/profile/select.go`: added a `Default` field to `SelectConfig`. When set, `SelectProfile` reorders so the default comes first and exposes `IsDefault` to templates. Existing callers that pass custom templates are unaffected. - `cmd/auth/profile_picker.go` (new): `pickAuthProfile` is the auth-package picker. It supports an `IncludeExtras` option for the "Create new" / "Enter host" entries used by `auth login` and `auth token`. - `cmd/auth/switch.go` and `cmd/auth/logout.go`: switched to `pickAuthProfile` with no extras and `Default` set. - `cmd/auth/token.go`: replaced the bespoke picker (`promptForProfileSelection`, `profileSelectItem`, `profileSelectionResult`) with `pickAuthProfile` (`IncludeExtras: true`, `Default` set). - `cmd/auth/login.go`: when the command is interactive and no `--profile`, `--host`, or positional argument is provided, show the same picker as `auth token`. Selecting an existing profile triggers a re-login (OAuth refresh) against that profile's host. ## Test plan - [x] New unit tests for the ordering helper in `libs/databrickscfg/profile/select_test.go` and for the picker in `cmd/auth/profile_picker_test.go`. - [x] All existing `cmd/auth/...` unit tests and `acceptance/cmd/auth/...` acceptance tests pass. - [x] `./task checks`, `./task fmt-q`, and `./task lint-q` are clean. This pull request and its description were written by Isaac.
1 parent 942e91c commit 83efe60

9 files changed

Lines changed: 428 additions & 127 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### CLI
66

77
* `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).
8+
* 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.
89

910
### Bundles
1011

cmd/auth/login.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,43 @@ a new profile is created.
180180
}
181181
}
182182

183+
// When interactive and nothing was specified, show a picker that lets
184+
// the user re-login to an existing profile, create a new one, or enter
185+
// a host URL. With no profiles configured the picker still shows the
186+
// two action entries so the user can choose between web-based discovery
187+
// (Create a new profile) and a manual host URL.
188+
if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) {
189+
allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles)
190+
if err != nil && !errors.Is(err, profile.ErrNoConfiguration) {
191+
return err
192+
}
193+
label := "Select a profile"
194+
if len(allProfiles) == 0 {
195+
label = "How would you like to log in?"
196+
}
197+
currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE"))
198+
result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{
199+
Label: label,
200+
Default: currentDefault,
201+
IncludeExtras: true,
202+
})
203+
if err != nil {
204+
return err
205+
}
206+
switch result {
207+
case profilePickerProfile:
208+
profileName = selected
209+
case profilePickerEnterHost:
210+
host, err := promptForHost(ctx)
211+
if err != nil {
212+
return err
213+
}
214+
authArguments.Host = host
215+
case profilePickerCreateNew:
216+
// Fall through to the profile name prompt below.
217+
}
218+
}
219+
183220
// If the user has not specified a profile name, prompt for one.
184221
if profileName == "" {
185222
var err error

cmd/auth/logout.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,19 @@ to specify it explicitly.
119119
if err != nil {
120120
return err
121121
}
122-
selected, err := profile.SelectProfile(ctx, profile.SelectConfig{
123-
Label: "Select a profile to log out of",
124-
Profiles: allProfiles,
125-
StartInSearchMode: len(allProfiles) > 5,
126-
ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`,
127-
InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`,
128-
SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`,
122+
currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE"))
123+
result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{
124+
Label: "Select a profile to log out of",
125+
SelectedNoun: "Selected profile",
126+
Default: currentDefault,
129127
})
130128
if err != nil {
131129
return err
132130
}
131+
// Without IncludeExtras, the picker only returns profile selections.
132+
if result != profilePickerProfile {
133+
return fmt.Errorf("unexpected picker result: %v", result)
134+
}
133135
profileName = selected
134136
}
135137

cmd/auth/profile_picker.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
8+
"github.com/databricks/cli/libs/cmdio"
9+
"github.com/databricks/cli/libs/databrickscfg/profile"
10+
)
11+
12+
// profilePickerResult represents the user's choice from pickAuthProfile.
13+
type profilePickerResult int
14+
15+
const (
16+
profilePickerProfile profilePickerResult = iota // an existing profile was picked
17+
profilePickerCreateNew // user chose "Create a new profile"
18+
profilePickerEnterHost // user chose "Enter a host URL manually"
19+
)
20+
21+
const (
22+
profilePickerCreateNewLabel = "Create a new profile"
23+
profilePickerEnterHostLabel = "Enter a host URL manually"
24+
)
25+
26+
// profilePickerOptions configures pickAuthProfile.
27+
type profilePickerOptions struct {
28+
// Label shown above the picker.
29+
Label string
30+
31+
// SelectedNoun is the noun shown after selection ("Using profile",
32+
// "Selected profile", "Default profile"). Defaults to "Using profile".
33+
SelectedNoun string
34+
35+
// Default is the name of the default profile. When set, it is moved to the
36+
// top of the list and decorated with "[default]".
37+
Default string
38+
39+
// IncludeExtras appends "Create a new profile" and "Enter a host URL
40+
// manually" entries after the profile list. Picker action entries are
41+
// shown even when the profile list is empty.
42+
IncludeExtras bool
43+
}
44+
45+
// pickerItem is a single entry rendered by the picker. It can be either a real
46+
// profile or one of the extra action entries (Create new / Enter host).
47+
type pickerItem struct {
48+
Name string
49+
Host string
50+
AccountID string
51+
IsDefault bool
52+
53+
// IsExtra distinguishes action entries (Create new / Enter host) from
54+
// real profiles, so a profile that happens to share a label name still
55+
// resolves correctly.
56+
IsExtra bool
57+
Extra profilePickerResult
58+
}
59+
60+
// buildPickerItems returns the items shown by pickAuthProfile, with the default
61+
// profile moved to the top and the extras appended (when requested).
62+
func buildPickerItems(profiles profile.Profiles, defaultName string, includeExtras bool) []pickerItem {
63+
defaultIdx := -1
64+
if defaultName != "" {
65+
for i, p := range profiles {
66+
if p.Name == defaultName {
67+
defaultIdx = i
68+
break
69+
}
70+
}
71+
}
72+
73+
itemFor := func(p profile.Profile, isDefault bool) pickerItem {
74+
return pickerItem{
75+
Name: p.Name,
76+
Host: p.Host,
77+
AccountID: p.AccountID,
78+
IsDefault: isDefault,
79+
}
80+
}
81+
82+
items := make([]pickerItem, 0, len(profiles)+2)
83+
if defaultIdx >= 0 {
84+
items = append(items, itemFor(profiles[defaultIdx], true))
85+
}
86+
for i, p := range profiles {
87+
if i == defaultIdx {
88+
continue
89+
}
90+
items = append(items, itemFor(p, false))
91+
}
92+
if includeExtras {
93+
items = append(items,
94+
pickerItem{Name: profilePickerCreateNewLabel, IsExtra: true, Extra: profilePickerCreateNew},
95+
pickerItem{Name: profilePickerEnterHostLabel, IsExtra: true, Extra: profilePickerEnterHost},
96+
)
97+
}
98+
return items
99+
}
100+
101+
// pickAuthProfile shows the auth profile picker and returns the user's choice.
102+
// When the result is profilePickerProfile, the second return value is the
103+
// selected profile name. For the other results it is empty.
104+
func pickAuthProfile(ctx context.Context, profiles profile.Profiles, opts profilePickerOptions) (profilePickerResult, string, error) {
105+
items := buildPickerItems(profiles, opts.Default, opts.IncludeExtras)
106+
if len(items) == 0 {
107+
return 0, "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile")
108+
}
109+
noun := opts.SelectedNoun
110+
if noun == "" {
111+
noun = "Using profile"
112+
}
113+
114+
idx, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{
115+
Label: opts.Label,
116+
Items: items,
117+
StartInSearchMode: len(profiles) > 5,
118+
Searcher: func(input string, index int) bool {
119+
input = strings.ToLower(input)
120+
return strings.Contains(strings.ToLower(items[index].Name), input) ||
121+
strings.Contains(strings.ToLower(items[index].Host), input) ||
122+
strings.Contains(strings.ToLower(items[index].AccountID), input)
123+
},
124+
LabelTemplate: "{{ . | faint }}",
125+
Active: `▸ {{.Name | bold}}{{if .IsDefault}} {{ "[default]" | green }}{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`,
126+
Inactive: ` {{.Name}}{{if .IsDefault}} [default]{{end}}{{if .AccountID}} (account: {{.AccountID|faint}}){{else if .Host}} ({{.Host|faint}}){{end}}`,
127+
Selected: `{{ "` + noun + `" | faint }}: {{ .Name | bold }}`,
128+
})
129+
if err != nil {
130+
return 0, "", err
131+
}
132+
133+
picked := items[idx]
134+
if picked.IsExtra {
135+
return picked.Extra, "", nil
136+
}
137+
return profilePickerProfile, picked.Name, nil
138+
}

cmd/auth/profile_picker_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
6+
"github.com/databricks/cli/libs/databrickscfg/profile"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestBuildPickerItems(t *testing.T) {
11+
profiles := profile.Profiles{
12+
{Name: "alpha", Host: "https://alpha.cloud.databricks.example"},
13+
{Name: "bravo", Host: "https://bravo.cloud.databricks.example"},
14+
{Name: "charlie", Host: "https://charlie.cloud.databricks.example"},
15+
}
16+
17+
cases := []struct {
18+
name string
19+
defaultName string
20+
includeExtras bool
21+
wantNames []string
22+
wantDefault string
23+
wantExtras []profilePickerResult
24+
}{
25+
{
26+
name: "no default no extras",
27+
wantNames: []string{"alpha", "bravo", "charlie"},
28+
wantDefault: "",
29+
},
30+
{
31+
name: "default moves to top",
32+
defaultName: "bravo",
33+
wantNames: []string{"bravo", "alpha", "charlie"},
34+
wantDefault: "bravo",
35+
},
36+
{
37+
name: "extras appended after profiles",
38+
includeExtras: true,
39+
wantNames: []string{"alpha", "bravo", "charlie", profilePickerCreateNewLabel, profilePickerEnterHostLabel},
40+
wantExtras: []profilePickerResult{profilePickerCreateNew, profilePickerEnterHost},
41+
},
42+
{
43+
name: "default first, then extras at the bottom",
44+
defaultName: "charlie",
45+
includeExtras: true,
46+
wantNames: []string{"charlie", "alpha", "bravo", profilePickerCreateNewLabel, profilePickerEnterHostLabel},
47+
wantDefault: "charlie",
48+
wantExtras: []profilePickerResult{profilePickerCreateNew, profilePickerEnterHost},
49+
},
50+
{
51+
name: "default not in profiles is ignored",
52+
defaultName: "missing",
53+
wantNames: []string{"alpha", "bravo", "charlie"},
54+
wantDefault: "",
55+
},
56+
}
57+
58+
t.Run("empty profiles with extras shows only extras", func(t *testing.T) {
59+
items := buildPickerItems(profile.Profiles{}, "", true)
60+
assert.Equal(t, []string{profilePickerCreateNewLabel, profilePickerEnterHostLabel}, namesOf(items))
61+
})
62+
63+
for _, tc := range cases {
64+
t.Run(tc.name, func(t *testing.T) {
65+
items := buildPickerItems(profiles, tc.defaultName, tc.includeExtras)
66+
67+
gotDefault := ""
68+
var gotExtras []profilePickerResult
69+
for _, it := range items {
70+
if it.IsDefault {
71+
assert.Empty(t, gotDefault)
72+
gotDefault = it.Name
73+
}
74+
if it.IsExtra {
75+
gotExtras = append(gotExtras, it.Extra)
76+
}
77+
}
78+
assert.Equal(t, tc.wantNames, namesOf(items))
79+
assert.Equal(t, tc.wantDefault, gotDefault)
80+
assert.Equal(t, tc.wantExtras, gotExtras)
81+
})
82+
}
83+
}
84+
85+
func namesOf(items []pickerItem) []string {
86+
names := make([]string, len(items))
87+
for i, it := range items {
88+
names[i] = it.Name
89+
}
90+
return names
91+
}

cmd/auth/switch.go

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package auth
22

33
import (
4-
"context"
54
"errors"
65
"fmt"
7-
"strings"
86

97
"github.com/databricks/cli/libs/cmdio"
108
"github.com/databricks/cli/libs/databrickscfg"
@@ -45,11 +43,23 @@ to see which profile is currently the default.`,
4543
}
4644

4745
currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, configFile)
48-
selectedName, err := promptForSwitchProfile(ctx, allProfiles, currentDefault)
46+
label := "Select a profile to set as default"
47+
if currentDefault != "" {
48+
label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault)
49+
}
50+
result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{
51+
Label: label,
52+
SelectedNoun: "Default profile",
53+
Default: currentDefault,
54+
})
4955
if err != nil {
5056
return err
5157
}
52-
profileName = selectedName
58+
// Without IncludeExtras, the picker only returns profile selections.
59+
if result != profilePickerProfile {
60+
return fmt.Errorf("unexpected picker result: %v", result)
61+
}
62+
profileName = selected
5363
} else {
5464
// Validate the profile exists.
5565
profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(profileName))
@@ -72,37 +82,3 @@ to see which profile is currently the default.`,
7282

7383
return cmd
7484
}
75-
76-
// promptForSwitchProfile shows an interactive profile picker for the switch command.
77-
// Reuses profileSelectItem from token.go for consistent display.
78-
func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles, currentDefault string) (string, error) {
79-
items := make([]profileSelectItem, 0, len(profiles))
80-
for _, p := range profiles {
81-
items = append(items, profileSelectItem{Name: p.Name, Host: p.Host})
82-
}
83-
84-
label := "Select a profile to set as default"
85-
if currentDefault != "" {
86-
label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault)
87-
}
88-
89-
i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{
90-
Label: label,
91-
Items: items,
92-
StartInSearchMode: len(profiles) > 5,
93-
Searcher: func(input string, index int) bool {
94-
input = strings.ToLower(input)
95-
name := strings.ToLower(items[index].Name)
96-
host := strings.ToLower(items[index].Host)
97-
return strings.Contains(name, input) || strings.Contains(host, input)
98-
},
99-
LabelTemplate: "{{ . | faint }}",
100-
Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`,
101-
Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`,
102-
Selected: `{{ "Default profile" | faint }}: {{ .Name | bold }}`,
103-
})
104-
if err != nil {
105-
return "", err
106-
}
107-
return profiles[i].Name, nil
108-
}

0 commit comments

Comments
 (0)