diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 348c2138560..da9bb562c92 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -45,9 +45,9 @@ func promptForHost(ctx context.Context) (string, error) { return "", errors.New("the command is being run in a non-interactive environment, please specify a host using --host") } - prompt := cmdio.Prompt(ctx) - prompt.Label = "Databricks host (e.g. https://.cloud.databricks.com)" - return prompt.Run() + return cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + Label: "Databricks host (e.g. https://.cloud.databricks.com)", + }) } func promptForAccountID(ctx context.Context) (string, error) { @@ -55,11 +55,10 @@ func promptForAccountID(ctx context.Context) (string, error) { return "", errors.New("the command is being run in a non-interactive environment, please specify an account ID using --account-id") } - prompt := cmdio.Prompt(ctx) - prompt.Label = "Databricks account ID" - prompt.Default = "" - prompt.AllowEdit = true - return prompt.Run() + return cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + Label: "Databricks account ID", + AllowEdit: true, + }) } // validateProfileHostConflict checks that --profile and --host don't conflict. diff --git a/cmd/auth/login.go b/cmd/auth/login.go index b7700e1cde2..496558f1992 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -32,10 +32,10 @@ func promptForProfile(ctx context.Context, defaultValue string) (string, error) return "", nil } - prompt := cmdio.Prompt(ctx) - prompt.Label = "Databricks profile name [" + defaultValue + "]" - prompt.AllowEdit = true - result, err := prompt.Run() + result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + Label: "Databricks profile name [" + defaultValue + "]", + AllowEdit: true, + }) if result == "" { // Manually return the default value. We could use the prompt.Default // field, but be inconsistent with other prompts in the CLI. @@ -756,10 +756,10 @@ func promptForWorkspaceSelection(ctx context.Context, authArguments *auth.AuthAr // promptForWorkspaceID asks the user to manually enter a workspace ID. // Returns empty string if the user provides no input. func promptForWorkspaceID(ctx context.Context) (string, error) { - prompt := cmdio.Prompt(ctx) - prompt.Label = "Enter workspace ID (empty to skip)" - prompt.AllowEdit = true - result, err := prompt.Run() + result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + Label: "Enter workspace ID (empty to skip)", + AllowEdit: true, + }) if err != nil { return "", err } diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 12cfa72a64a..2ff7dfad1a3 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -10,7 +10,6 @@ import ( "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -87,7 +86,7 @@ func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles, curr label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault) } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ Label: label, Items: items, StartInSearchMode: len(profiles) > 5, @@ -97,12 +96,10 @@ func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles, curr host := strings.ToLower(items[index].Host) return strings.Contains(name, input) || strings.Contains(host, input) }, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`, - Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`, - Selected: `{{ "Default profile" | faint }}: {{ .Name | bold }}`, - }, + 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 diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 9433b6238da..67dc56807ca 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -21,7 +21,6 @@ import ( "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/oauth2" ) @@ -379,8 +378,8 @@ type profileSelectItem struct { Host string } -// promptForProfileSelection shows a promptui select list with all configured -// profiles plus "Enter a host URL" and "Create a new profile" options. +// 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) @@ -392,7 +391,7 @@ func promptForProfileSelection(ctx context.Context, profiles profile.Profiles) ( enterHostIdx := len(items) items = append(items, profileSelectItem{Name: "Enter a host URL manually"}) - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ Label: "Select a profile", Items: items, StartInSearchMode: len(profiles) > 5, @@ -402,12 +401,10 @@ func promptForProfileSelection(ctx context.Context, profiles profile.Profiles) ( host := strings.ToLower(items[index].Host) return strings.Contains(name, input) || strings.Contains(host, input) }, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`, - Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`, - Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, - }, + 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 diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index 0d6ad09def4..dbfff1a3626 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -27,14 +27,14 @@ func configureInteractive(cmd *cobra.Command, flags *configureFlags, cfg *config // Ask user to specify the host if not already set. if cfg.Host == "" { - prompt := cmdio.Prompt(ctx) - prompt.Label = "Databricks workspace host (https://...)" - prompt.AllowEdit = true - prompt.Validate = func(input string) error { - normalized := normalizeHost(input) - return validateHost(normalized) - } - out, err := prompt.Run() + out, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + Label: "Databricks workspace host (https://...)", + AllowEdit: true, + Validate: func(input string) error { + normalized := normalizeHost(input) + return validateHost(normalized) + }, + }) if err != nil { return err } @@ -43,10 +43,10 @@ func configureInteractive(cmd *cobra.Command, flags *configureFlags, cfg *config // Ask user to specify the token is not already set. if cfg.Token == "" { - prompt := cmdio.Prompt(ctx) - prompt.Label = "Personal access token" - prompt.Mask = '*' - out, err := prompt.Run() + out, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + Label: "Personal access token", + Mask: '*', + }) if err != nil { return err } diff --git a/cmd/root/bundle_test.go b/cmd/root/bundle_test.go index 8c021fe77f2..87401150fb8 100644 --- a/cmd/root/bundle_test.go +++ b/cmd/root/bundle_test.go @@ -265,7 +265,7 @@ workspace: }() // Verify the prompt fires by reading output from stderr. - // promptui with StartInSearchMode writes a search cursor first. + // cmdio.RunSelect with StartInSearchMode writes a search cursor first. line, _, readErr := io.Stderr.ReadLine() if assert.NoError(t, readErr, "expected prompt output on stderr") { assert.Contains(t, string(line), "Search:") diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index 5477cd35125..d4c4f42e27a 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -161,21 +161,6 @@ func (nopWriteCloser) Close() error { return nil } -func Prompt(ctx context.Context) *promptui.Prompt { - c := fromContext(ctx) - return &promptui.Prompt{ - Stdin: c.promptStdin(), - Stdout: nopWriteCloser{c.err}, - } -} - -func RunSelect(ctx context.Context, prompt *promptui.Select) (int, string, error) { - c := fromContext(ctx) - prompt.Stdin = c.promptStdin() - prompt.Stdout = nopWriteCloser{c.err} - return prompt.Run() -} - // NewSpinner creates a new spinner for displaying progress indicators. // The returned spinner should be closed when done to release resources. // diff --git a/libs/cmdio/prompt.go b/libs/cmdio/prompt.go new file mode 100644 index 00000000000..9fccbf09e82 --- /dev/null +++ b/libs/cmdio/prompt.go @@ -0,0 +1,42 @@ +package cmdio + +import ( + "context" + + "github.com/manifoldco/promptui" +) + +// PromptOptions configures a single-line text prompt shown by [RunPrompt]. +type PromptOptions struct { + // Label is shown before the input field. Required. + Label string + + // Default is the value pre-filled in the input field. + Default string + + // Mask, when non-zero, replaces typed characters with the given rune + // (use '*' for password-style input). + Mask rune + + // AllowEdit lets the user edit Default rather than overwriting it. + AllowEdit bool + + // Validate, when set, is called on every keystroke; returning a non-nil + // error keeps the prompt open and shows the error to the user. + Validate func(input string) error +} + +// RunPrompt shows a single-line text prompt and returns the entered value. +func RunPrompt(ctx context.Context, opts PromptOptions) (string, error) { + c := fromContext(ctx) + p := promptui.Prompt{ + Label: opts.Label, + Default: opts.Default, + Mask: opts.Mask, + AllowEdit: opts.AllowEdit, + Validate: opts.Validate, + Stdin: c.promptStdin(), + Stdout: nopWriteCloser{c.err}, + } + return p.Run() +} diff --git a/libs/cmdio/select.go b/libs/cmdio/select.go new file mode 100644 index 00000000000..186e7a0e760 --- /dev/null +++ b/libs/cmdio/select.go @@ -0,0 +1,58 @@ +package cmdio + +import ( + "context" + + "github.com/manifoldco/promptui" +) + +// SelectOptions configures an interactive single-choice picker shown by +// [RunSelect]. Template strings use text/template syntax and have access +// to the fields of the items in Items. +type SelectOptions struct { + // Label is shown above the list. Required. + Label string + + // Items is the slice of values to choose from. Templates reference + // fields on the element type. + Items any + + // Searcher, when set, narrows the list as the user types. + Searcher func(input string, index int) bool + + // StartInSearchMode opens the prompt with the search input focused. + StartInSearchMode bool + + // LabelTemplate renders Label. Empty uses the default. + LabelTemplate string + + // Active renders the highlighted item. + Active string + + // Inactive renders non-highlighted items. + Inactive string + + // Selected renders the chosen item after the prompt closes. + Selected string +} + +// RunSelect shows an interactive picker and returns the index of the chosen item. +func RunSelect(ctx context.Context, opts SelectOptions) (int, error) { + c := fromContext(ctx) + sel := &promptui.Select{ + Label: opts.Label, + Items: opts.Items, + Searcher: opts.Searcher, + StartInSearchMode: opts.StartInSearchMode, + Templates: &promptui.SelectTemplates{ + Label: opts.LabelTemplate, + Active: opts.Active, + Inactive: opts.Inactive, + Selected: opts.Selected, + }, + Stdin: c.promptStdin(), + Stdout: nopWriteCloser{c.err}, + } + idx, _, err := sel.Run() + return idx, err +} diff --git a/libs/databrickscfg/cfgpickers/clusters.go b/libs/databrickscfg/cfgpickers/clusters.go index 1bedf0dcde7..6732c1893c7 100644 --- a/libs/databrickscfg/cfgpickers/clusters.go +++ b/libs/databrickscfg/cfgpickers/clusters.go @@ -11,7 +11,6 @@ import ( "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/iam" - "github.com/manifoldco/promptui" "golang.org/x/mod/semver" ) @@ -193,7 +192,7 @@ func AskForCluster(ctx context.Context, w *databricks.WorkspaceClient, filters . if len(compatible) == 1 { return compatible[0].ClusterId, nil } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ Label: "Choose compatible cluster", Items: compatible, Searcher: func(input string, idx int) bool { @@ -201,12 +200,10 @@ func AskForCluster(ctx context.Context, w *databricks.WorkspaceClient, filters . return strings.Contains(lower, strings.ToLower(input)) }, StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{.ClusterName | faint}}", - Active: `{{.ClusterName | bold}} ({{.State}} {{.Access}} Runtime {{.Runtime}}) ({{.ClusterId | faint}})`, - Inactive: `{{.ClusterName}} ({{.State}} {{.Access}} Runtime {{.Runtime}})`, - Selected: `{{ "Configured cluster" | faint }}: {{ .ClusterName | bold }} ({{.ClusterId | faint}})`, - }, + LabelTemplate: "{{.ClusterName | faint}}", + Active: `{{.ClusterName | bold}} ({{.State}} {{.Access}} Runtime {{.Runtime}}) ({{.ClusterId | faint}})`, + Inactive: `{{.ClusterName}} ({{.State}} {{.Access}} Runtime {{.Runtime}})`, + Selected: `{{ "Configured cluster" | faint }}: {{ .ClusterName | bold }} ({{.ClusterId | faint}})`, }) if err != nil { return "", err diff --git a/libs/databrickscfg/profile/profile.go b/libs/databrickscfg/profile/profile.go index 7d2b8f715a4..efd358cd4e5 100644 --- a/libs/databrickscfg/profile/profile.go +++ b/libs/databrickscfg/profile/profile.go @@ -37,8 +37,8 @@ func (p Profile) Cloud() string { type Profiles []Profile -// SearchCaseInsensitive implements the promptui.Searcher interface. -// This allows the user to immediately starting typing to narrow down the list. +// SearchCaseInsensitive matches the cmdio.SelectOptions.Searcher signature so +// the user can immediately start typing to narrow down the list. func (p Profiles) SearchCaseInsensitive(input string, index int) bool { input = strings.ToLower(input) name := strings.ToLower(p[index].Name) diff --git a/libs/databrickscfg/profile/select.go b/libs/databrickscfg/profile/select.go index dde2c35bc09..d0470ef58fa 100644 --- a/libs/databrickscfg/profile/select.go +++ b/libs/databrickscfg/profile/select.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/databricks/cli/libs/cmdio" - "github.com/manifoldco/promptui" ) var ( @@ -78,8 +77,8 @@ func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { } // Build the searcher from the items slice directly so it stays coupled - // to the Items list passed to promptui (rather than the original Profiles - // slice which could diverge if items were ever filtered or reordered). + // to the Items list passed to cmdio.RunSelect (rather than the original + // Profiles slice which could diverge if items were ever filtered or reordered). searcher := func(input string, index int) bool { input = strings.ToLower(input) p := items[index].Profile @@ -88,17 +87,15 @@ func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { strings.Contains(strings.ToLower(p.AccountID), input) } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + i, err := cmdio.RunSelect(ctx, cmdio.SelectOptions{ Label: cfg.Label, Items: items, StartInSearchMode: cfg.StartInSearchMode, Searcher: searcher, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: cfg.ActiveTemplate, - Inactive: cfg.InactiveTemplate, - Selected: cfg.SelectedTemplate, - }, + LabelTemplate: "{{ . | faint }}", + Active: cfg.ActiveTemplate, + Inactive: cfg.InactiveTemplate, + Selected: cfg.SelectedTemplate, }) if err != nil { return "", err