Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### CLI

* `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.

### Bundles

Expand Down
37 changes: 37 additions & 0 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,43 @@ 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. 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
}
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
}
authArguments.Host = host
Comment on lines +209 to +214
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] Do we want this to fall through to the profile name prompt bellow?

From playing around if I introduce the host of a workspace for which I already have a profile I get asked to input a profile name and it creates a new profile.

Is this what we want or should we have some discovery mechanism where we show the available profiles for that host?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to keep it simple, and assume you want to create a new profile in this case

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
Expand Down
16 changes: 9 additions & 7 deletions cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,19 @@ 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",
SelectedNoun: "Selected profile",
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
}

Expand Down
138 changes: 138 additions & 0 deletions cmd/auth/profile_picker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package auth

import (
"context"
"errors"
"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

// 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. Picker action entries are
// shown even when the profile list is empty.
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},
)
}
Comment on lines +92 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Question] From the point of view of the semantics of these commands it makes sense to have the extras at the bottom. You call login not a hypothetical create. However, if you actually want to create a profile you have to go though login and if you have multiple accounts you have to go through ALL of them and reach the bottom of the list.

Is this something we should acknowledge but accept or should we move the extra options at the top and then have the profiles bellow?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is ultimately, do we think people are more likely to use databricks auth login to refresh tokens for existing profiles or to create new profiles. My assumption is that the more profiles you have, the less likely you are to want to create new profiles (and also the more advanced a user you would be). So thats why the behavior is how it is right now

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)
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,
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: `{{ "` + noun + `" | faint }}: {{ .Name | bold }}`,
})
if err != nil {
return 0, "", err
}

picked := items[idx]
if picked.IsExtra {
return picked.Extra, "", nil
}
return profilePickerProfile, picked.Name, nil
}
91 changes: 91 additions & 0 deletions cmd/auth/profile_picker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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: "",
},
}

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)

gotDefault := ""
var gotExtras []profilePickerResult
for _, it := range items {
if it.IsDefault {
assert.Empty(t, gotDefault)
gotDefault = it.Name
}
if it.IsExtra {
gotExtras = append(gotExtras, it.Extra)
}
}
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
}
52 changes: 14 additions & 38 deletions cmd/auth/switch.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package auth

import (
"context"
"errors"
"fmt"
"strings"

"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg"
Expand Down Expand Up @@ -45,11 +43,23 @@ 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,
SelectedNoun: "Default profile",
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))
Expand All @@ -72,37 +82,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
}
Loading
Loading