Skip to content
Open
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
135 changes: 115 additions & 20 deletions internal/iostreams/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import (
"fmt"
"strings"

lipgloss "charm.land/lipgloss/v2"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/pflag"
)

Expand All @@ -31,6 +33,23 @@ type PromptConfig interface {
IsRequired() bool // IsRequired returns if a response must be provided
}

// PromptOption pairs an interactive option label with the flag invocation
// that picks the same option non-interactively. When a prompt is reached in
// a non-TTY context, the resulting error renders one of these per option so
// agents and scripts can re-run with the right --flag=value.
type PromptOption struct {
Label string // The option as rendered in the interactive list
Flag *pflag.Flag // The flag substitute for this option
Value string // The value to pass, e.g. "T0123" or "A0ABCD"
}

// PromptOptionsConfig is optionally implemented by prompt configs that can
// enumerate options as flag invocations. Configs that do not implement it
// (or return an empty slice) keep the simpler "Try --flag" remediation.
type PromptOptionsConfig interface {
GetPromptOptions() []PromptOption
}

Comment on lines +36 to +52

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.

note: New structs to allow us to pass flag names and values that correspond to each option. For now, I've kept it simple with just 1 flag. If we discover prompts that require multiple flags (e.g. --template <url> --subdir <path> then we can refactor later. For now, I'm leaning toward setting up the essentials to keep it readable.

// ConfirmPromptConfig holds additional configs for a Confirm prompt
type ConfirmPromptConfig struct {
Required bool // If a response is required
Expand Down Expand Up @@ -64,14 +83,20 @@ func (cfg InputPromptConfig) IsRequired() bool {

// MultiSelectPromptConfig holds additional configs for a MultiSelect prompt
type MultiSelectPromptConfig struct {
Required bool // If a response is required
Options []PromptOption // Optional flag invocations parallel to the prompt's options
Required bool // If a response is required
}

// GetFlags returns all flags for the MultiSelect prompt
func (cfg MultiSelectPromptConfig) GetFlags() []*pflag.Flag {
return []*pflag.Flag{}
}

// GetPromptOptions returns flag invocations for each option, when set
func (cfg MultiSelectPromptConfig) GetPromptOptions() []PromptOption {
return cfg.Options
}

// IsRequired returns if a response is required
func (cfg MultiSelectPromptConfig) IsRequired() bool {
return cfg.Required
Expand Down Expand Up @@ -112,6 +137,7 @@ type SelectPromptConfig struct {
Flag *pflag.Flag // The single flag substitute for this prompt
Flags []*pflag.Flag // Otherwise multiple flag substitutes for this prompt
Help string // Optional help text displayed below the select title
Options []PromptOption // Optional flag invocations parallel to the prompt's options
PageSize int // DEPRECATED: The number of options displayed before the user needs to scroll
Required bool // If a response is required
Template string // DEPRECATED: Custom formatting of the selection prompt
Expand All @@ -129,6 +155,11 @@ func (cfg SelectPromptConfig) GetFlags() []*pflag.Flag {
}
}

// GetPromptOptions returns flag invocations for each option, when set
func (cfg SelectPromptConfig) GetPromptOptions() []PromptOption {
return cfg.Options
}

// IsRequired returns if a response is required
func (cfg SelectPromptConfig) IsRequired() bool {
return cfg.Required
Expand Down Expand Up @@ -163,37 +194,101 @@ func (io *IOStreams) retrieveFlagValue(flagset []*pflag.Flag) (*pflag.Flag, erro
return flag, nil
}

// errInteractivityFlags formats an error for when flag substitutes are needed
func errInteractivityFlags(cfg PromptConfig) error {
// errInteractivityFlags formats an error for when flag substitutes are needed.
// It re-renders the prompt question and any enumerable options (with their
// equivalent --flag=value invocations) as part of the error body so agents
// and devops scripts can read the error and re-run with the right flags.
// The Suggestion remains a short, single-line directive.
func errInteractivityFlags(cfg PromptConfig, message string, options []string) error {
Comment on lines +197 to +202

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.

note: This is the meat and potatoes of the PR 🥔 🍠 It handles rendering the error message, which now support displaying the original prompt that should have been shown.

flags := cfg.GetFlags()
var remediation string
var helpMessage = "Learn more about this command with `--help`"

if len(flags) == 1 {
remediation = fmt.Sprintf("Try running the command with the `--%s` flag included", flags[0].Name)
helpMessage = "Learn more about this flag with `--help`"
} else if len(flags) > 1 {
var names []string
var promptOptions []PromptOption
if oc, ok := cfg.(PromptOptionsConfig); ok {
promptOptions = oc.GetPromptOptions()
}
if len(options) > 0 && len(promptOptions) != len(options) {
promptOptions = nil
}

hasFlagOptions := false
for _, opt := range promptOptions {
if opt.Flag != nil && opt.Value != "" {
hasFlagOptions = true
break
}
}

body := []string{"The input device is not a TTY or does not support interactivity"}
if message != "" || len(promptOptions) > 0 || len(options) > 0 {
body = append(body, "The prompt that would have been shown is below:", "")
}
if message != "" {
body = append(body, fmt.Sprintf("› %s", message))
}

switch {
case len(promptOptions) > 0:
labelWidth := 0
if hasFlagOptions {
for _, opt := range promptOptions {
if opt.Flag == nil || opt.Value == "" {
continue
}
if w := lipgloss.Width(opt.Label); w > labelWidth {
labelWidth = w
}
}
}
for _, opt := range promptOptions {
if opt.Flag == nil || opt.Value == "" {
body = append(body, fmt.Sprintf(" %s", opt.Label))
continue
}
padding := max(labelWidth-lipgloss.Width(opt.Label), 0)
flagText := style.Secondary(fmt.Sprintf("--%s=%s", opt.Flag.Name, opt.Value))
body = append(body, fmt.Sprintf(" %s%s %s", opt.Label, strings.Repeat(" ", padding), flagText))
}
case len(options) > 0:
for _, opt := range options {
body = append(body, fmt.Sprintf(" %s", opt))
}
}

var remediation string
switch {
case hasFlagOptions:
var flagName string
for _, opt := range promptOptions {
if opt.Flag != nil {
flagName = opt.Flag.Name
break
}
}
remediation = fmt.Sprintf("Re-run with one of the `--%s` values shown above", flagName)
case len(flags) == 1:
remediation = fmt.Sprintf("Try running the command with the `--%s` flag included\nLearn more about this flag with `--help`", flags[0].Name)
case len(flags) > 1:
names := make([]string, 0, len(flags))
for _, flag := range flags {
names = append(names, flag.Name)
}
flags := strings.Join(names, "`\n `--")
remediation = fmt.Sprintf("Consider using the following flags when running this command:\n `--%s`", flags)
helpMessage = "Learn more about these flags with `--help`"
remediation = fmt.Sprintf("Consider using the following flags when running this command:\n `--%s`\nLearn more about these flags with `--help`", strings.Join(names, "`\n `--"))
default:
remediation = "Learn more about this command with `--help`"
}

return slackerror.New(slackerror.ErrPrompt).
WithDetails(slackerror.ErrorDetails{
slackerror.ErrorDetail{Message: "The input device is not a TTY or does not support interactivity"},
slackerror.ErrorDetail{Message: strings.Join(body, "\n")},
}).
WithRemediation("%s\n%s", remediation, helpMessage)
WithRemediation("%s", remediation)
}

// ConfirmPrompt prompts the user for a "yes" or "no" (true or false) value for
// the message
func (io *IOStreams) ConfirmPrompt(ctx context.Context, message string, defaultValue bool) (bool, error) {
if !io.IsTTY() {
return false, errInteractivityFlags(ConfirmPromptConfig{})
return false, errInteractivityFlags(ConfirmPromptConfig{}, message, nil)
}
return confirmForm(io, ctx, message, defaultValue)
}
Expand All @@ -203,7 +298,7 @@ func (io *IOStreams) ConfirmPrompt(ctx context.Context, message string, defaultV
func (io *IOStreams) InputPrompt(ctx context.Context, message string, cfg InputPromptConfig) (string, error) {
if !io.IsTTY() {
if cfg.IsRequired() {
return "", errInteractivityFlags(cfg)
return "", errInteractivityFlags(cfg, message, nil)
}
return "", nil
}
Expand All @@ -214,7 +309,7 @@ func (io *IOStreams) InputPrompt(ctx context.Context, message string, cfg InputP
// returns the selected values
func (io *IOStreams) MultiSelectPrompt(ctx context.Context, message string, options []string) ([]string, error) {
if !io.IsTTY() {
return nil, errInteractivityFlags(MultiSelectPromptConfig{})
return nil, errInteractivityFlags(MultiSelectPromptConfig{}, message, options)
}
return multiSelectForm(io, ctx, message, options)
}
Expand All @@ -228,7 +323,7 @@ func (io *IOStreams) PasswordPrompt(ctx context.Context, message string, cfg Pas
return PasswordPromptResponse{Flag: true, Value: cfg.Flag.Value.String()}, nil
}
if !io.IsTTY() {
return PasswordPromptResponse{}, errInteractivityFlags(cfg)
return PasswordPromptResponse{}, errInteractivityFlags(cfg, message, nil)
}

return passwordForm(io, ctx, message, cfg)
Expand All @@ -250,7 +345,7 @@ func (io *IOStreams) SelectPrompt(ctx context.Context, msg string, options []str
}
if !io.IsTTY() {
if cfg.IsRequired() {
return SelectPromptResponse{}, errInteractivityFlags(cfg)
return SelectPromptResponse{}, errInteractivityFlags(cfg, msg, options)
} else {
return SelectPromptResponse{}, nil
}
Expand Down
74 changes: 73 additions & 1 deletion internal/iostreams/prompts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ func TestRetrieveFlagValue(t *testing.T) {
func TestErrInteractivityFlags(t *testing.T) {
tests := map[string]struct {
cfg PromptConfig
message string
options []string
contains []string
excludes []string
}{
"no flags shows generic message": {
cfg: ConfirmPromptConfig{},
Expand All @@ -196,17 +199,86 @@ func TestErrInteractivityFlags(t *testing.T) {
}},
contains: []string{"--app", "--team"},
},
"renders question and per-option flag invocations when provided": {
cfg: SelectPromptConfig{
Flag: &pflag.Flag{Name: "team"},
Options: []PromptOption{
{Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"},
{Label: "team-two", Flag: &pflag.Flag{Name: "team"}, Value: "T0002"},
},
},
message: "Choose a team",
options: []string{"team-one", "team-two"},
contains: []string{
"prompt that would have been shown",
"› Choose a team",
"team-one",
"--team=T0001",
"team-two",
"--team=T0002",
"Re-run with one of the `--team` values shown above",
},
},
"degrades to flag suggestion when option count mismatches": {
cfg: SelectPromptConfig{
Flag: &pflag.Flag{Name: "team"},
Options: []PromptOption{
{Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"},
},
},
message: "Choose a team",
options: []string{"team-one", "team-two"},
contains: []string{
"--team",
"› Choose a team",
"team-one",
"team-two",
},
excludes: []string{"--team=T0001", "Re-run with one of"},
},
"renders question even without options": {
cfg: InputPromptConfig{Required: true},
message: "Enter a name",
contains: []string{"› Enter a name"},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
err := errInteractivityFlags(tc.cfg)
err := errInteractivityFlags(tc.cfg, tc.message, tc.options)
for _, s := range tc.contains {
assert.Contains(t, err.Error(), s)
}
for _, s := range tc.excludes {
assert.NotContains(t, err.Error(), s)
}
})
}
}

func TestErrInteractivityFlags_StructuredDetails(t *testing.T) {
cfg := SelectPromptConfig{
Flag: &pflag.Flag{Name: "team"},
Options: []PromptOption{
{Label: "team-one", Flag: &pflag.Flag{Name: "team"}, Value: "T0001"},
{Label: "team-two", Flag: &pflag.Flag{Name: "team"}, Value: "T0002"},
},
}
err := errInteractivityFlags(cfg, "Choose a team", []string{"team-one", "team-two"})
se := slackerror.ToSlackError(err)

assert.Equal(t, slackerror.ErrPrompt, se.Code)
require.Len(t, se.Details, 1)

body := se.Details[0].Message
assert.Contains(t, body, "not a TTY")
assert.Contains(t, body, "prompt that would have been shown")
assert.Contains(t, body, "› Choose a team")
assert.Contains(t, body, "--team=T0001")
assert.Contains(t, body, "--team=T0002")

assert.Equal(t, "Re-run with one of the `--team` values shown above", se.Remediation)
}

func TestPasswordPrompt(t *testing.T) {
tests := map[string]struct {
flagChanged bool
Expand Down
24 changes: 18 additions & 6 deletions internal/prompts/app_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,9 +601,19 @@ func AppSelectPrompt(
if cfg.includeNoApp {
options = append(options, Selection{label: noApp})
}
appFlag := clients.Config.Flags.Lookup("app")
labels := []string{}
for _, label := range options {
labels = append(labels, label.label)
appOptions := []iostreams.PromptOption{}
for _, opt := range options {
labels = append(labels, opt.label)
// Synthetic entries ("Create a new app", "No app") have no AppID and
// are emitted as label-only so the option list stays 1:1 with labels.
promptOpt := iostreams.PromptOption{Label: opt.label}
if opt.app.App.AppID != "" {
promptOpt.Flag = appFlag
promptOpt.Value = opt.app.App.AppID
}
appOptions = append(appOptions, promptOpt)
Comment on lines +604 to +616

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.

note: This is the migration of the App Select prompt. We have 36 total call sites, so this gives you an idea of what a straight-forward migration will look like. I've scoped this PR to just migrate 2 call sites to allow us to focus on the design and infrastructure changes.

}
switch {
case types.IsAppID(clients.Config.AppFlag):
Expand Down Expand Up @@ -633,11 +643,13 @@ func AppSelectPrompt(
labels,
iostreams.SelectPromptConfig{
Required: true,
Options: appOptions,

// Flag is checked before since the value might be an app "environment" while
// an app ID is required in the return.
//
// Flag: clients.Config.Flags.Lookup("app"),
// Flag is intentionally not set: --app may be an environment value
// ("local", "deployed") which must not be matched against an app
// ID. The IsAppID and IsAppFlagEnvironment branches above handle
// the cases where --app is set; Options provides per-option
// flag-substitute hints for the non-TTY error path.
})
if err != nil {
return SelectedApp{}, err
Expand Down
16 changes: 11 additions & 5 deletions internal/prompts/team_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,23 @@ func PromptTeamSlackAuth(ctx context.Context, clients *shared.ClientFactory, pro
return strings.Compare(i.TeamDomain, j.TeamDomain)
})

teamFlag := clients.Config.Flags.Lookup("team")
var teamLabels []string
var teamOptions []iostreams.PromptOption
for _, auth := range allAuths {
teamLabels = append(
teamLabels,
style.TeamSelectLabel(auth.TeamDomain, auth.TeamID),
)
label := style.TeamSelectLabel(auth.TeamDomain, auth.TeamID)
teamLabels = append(teamLabels, label)
teamOptions = append(teamOptions, iostreams.PromptOption{
Label: label,
Flag: teamFlag,
Value: auth.TeamID,
})
Comment on lines +53 to +63

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.

note: This is the migration of the Team Select prompt. We have 36 total call sites, so this gives you an idea of what a straight-forward migration will look like. I've scoped this PR to just migrate 2 call sites to allow us to focus on the design and infrastructure changes.

}

selectPromptConfig := iostreams.SelectPromptConfig{
Required: true,
Flag: clients.Config.Flags.Lookup("team"),
Flag: teamFlag,
Options: teamOptions,
}
if promptConfig != nil && promptConfig.HelpText != "" {
selectPromptConfig.Help = promptConfig.HelpText
Expand Down
Loading