github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/prompt/prompt.go (about)

     1  package prompt
     2  
     3  import (
     4  	"runtime"
     5  	"strings"
     6  
     7  	"github.com/ActiveState/cli/internal/analytics/dimensions"
     8  	"gopkg.in/AlecAivazis/survey.v1"
     9  	"gopkg.in/AlecAivazis/survey.v1/terminal"
    10  
    11  	"github.com/ActiveState/cli/internal/analytics/constants"
    12  	"github.com/ActiveState/cli/internal/locale"
    13  	"github.com/ActiveState/cli/internal/logging"
    14  	"github.com/ActiveState/cli/internal/output"
    15  )
    16  
    17  type EventDispatcher interface {
    18  	EventWithLabel(category, action string, label string, dim ...*dimensions.Values)
    19  }
    20  
    21  // Prompter is the interface used to run our prompt from, useful for mocking in tests
    22  type Prompter interface {
    23  	Input(title, message string, defaultResponse *string, flags ...ValidatorFlag) (string, error)
    24  	InputAndValidate(title, message string, defaultResponse *string, validator ValidatorFunc, flags ...ValidatorFlag) (string, error)
    25  	Select(title, message string, choices []string, defaultResponse *string) (string, error)
    26  	Confirm(title, message string, defaultChoice *bool) (bool, error)
    27  	InputSecret(title, message string, flags ...ValidatorFlag) (string, error)
    28  	IsInteractive() bool
    29  }
    30  
    31  // ValidatorFunc is a function pass to the Prompter to perform validation
    32  // on the users input
    33  type ValidatorFunc = survey.Validator
    34  
    35  var _ Prompter = &Prompt{}
    36  
    37  // Prompt is our main prompting struct
    38  type Prompt struct {
    39  	out           output.Outputer
    40  	analytics     EventDispatcher
    41  	isInteractive bool
    42  }
    43  
    44  // New creates a new prompter
    45  func New(isInteractive bool, an EventDispatcher) Prompter {
    46  	return &Prompt{output.Get(), an, isInteractive}
    47  }
    48  
    49  // IsInteractive checks if the prompts can be interactive or should just return default values
    50  func (p *Prompt) IsInteractive() bool {
    51  	return p.isInteractive
    52  }
    53  
    54  // ValidatorFlag represents flags for prompt functions to change their behavior on.
    55  type ValidatorFlag int
    56  
    57  const (
    58  	// InputRequired requires that the user provide input
    59  	InputRequired ValidatorFlag = iota
    60  	// IsAlpha
    61  	// IsNumber
    62  	// etc.
    63  )
    64  
    65  // Input prompts the user for input.  The user can specify available validation flags to trigger validation of responses
    66  func (p *Prompt) Input(title, message string, defaultResponse *string, flags ...ValidatorFlag) (string, error) {
    67  	return p.InputAndValidate(title, message, defaultResponse, func(val interface{}) error {
    68  		return nil
    69  	}, flags...)
    70  }
    71  
    72  // interactiveInputError returns the proper input error for a non-interactive prompt.
    73  // If the terminal cannot show prompts (e.g. Git Bash on Windows), the error mentions this.
    74  // Otherwise, the error simply states the prompt cannot be resolved in non-interactive mode.
    75  // The "message" argument is the prompt's user-facing message.
    76  func interactiveInputError(message string) error {
    77  	if runtime.GOOS == "windows" {
    78  		return locale.NewExternalError("err_non_interactive_mode")
    79  	}
    80  	return locale.NewExternalError("err_non_interactive_prompt", message)
    81  }
    82  
    83  // InputAndValidate prompts an input field and allows you to specfiy a custom validation function as well as the built in flags
    84  func (p *Prompt) InputAndValidate(title, message string, defaultResponse *string, validator ValidatorFunc, flags ...ValidatorFlag) (string, error) {
    85  	if !p.isInteractive {
    86  		if defaultResponse != nil {
    87  			logging.Debug("Selecting default choice %s for Input prompt %s in non-interactive mode", *defaultResponse, title)
    88  			return *defaultResponse, nil
    89  		}
    90  		return "", interactiveInputError(message)
    91  	}
    92  
    93  	var response string
    94  	flagValidators, err := processValidators(flags)
    95  	if err != nil {
    96  		return "", err
    97  	}
    98  	if len(flagValidators) != 0 {
    99  		validator = wrapValidators(append(flagValidators, validator))
   100  	}
   101  
   102  	if title != "" {
   103  		p.out.Notice(output.Emphasize(title))
   104  	}
   105  
   106  	// We handle defaults more clearly than the survey package can
   107  	if defaultResponse != nil && *defaultResponse != "" {
   108  		v, err := p.Select("", formatMessage(message, !p.out.Config().Colored), []string{*defaultResponse, locale.Tl("prompt_custom", "Other ..")}, defaultResponse)
   109  		if err != nil {
   110  			return "", err
   111  		}
   112  		if v == *defaultResponse {
   113  			return v, nil
   114  		}
   115  		message = ""
   116  	}
   117  
   118  	err = survey.AskOne(&Input{&survey.Input{
   119  		Message: formatMessage(message, !p.out.Config().Colored),
   120  	}}, &response, validator)
   121  	if err != nil {
   122  		return "", locale.NewInputError(err.Error())
   123  	}
   124  
   125  	return response, nil
   126  }
   127  
   128  // Select prompts the user to select one entry from multiple choices
   129  func (p *Prompt) Select(title, message string, choices []string, defaultChoice *string) (string, error) {
   130  	if !p.isInteractive {
   131  		if defaultChoice != nil {
   132  			logging.Debug("Selecting default choice %s for Select prompt %s in non-interactive mode", *defaultChoice, title)
   133  			return *defaultChoice, nil
   134  		}
   135  		return "", interactiveInputError(message)
   136  	}
   137  
   138  	if title != "" {
   139  		p.out.Notice(output.Emphasize(title))
   140  	}
   141  
   142  	var defChoice string
   143  	if defaultChoice != nil {
   144  		defChoice = *defaultChoice
   145  	}
   146  
   147  	var response string
   148  	err := survey.AskOne(&Select{&survey.Select{
   149  		Message:  formatMessage(message, !p.out.Config().Colored),
   150  		Options:  choices,
   151  		Default:  defChoice,
   152  		FilterFn: func(input string, choices []string) []string { return choices }, // no filter
   153  	}}, &response, nil)
   154  	if err != nil {
   155  		return "", locale.NewInputError(err.Error())
   156  	}
   157  	return response, nil
   158  }
   159  
   160  // Confirm prompts user for yes or no response.
   161  func (p *Prompt) Confirm(title, message string, defaultChoice *bool) (bool, error) {
   162  	if !p.isInteractive {
   163  		if defaultChoice != nil {
   164  			logging.Debug("Prompt %s confirmed with default choice %v in non-interactive mode", title, defaultChoice)
   165  			return *defaultChoice, nil
   166  		}
   167  		return false, interactiveInputError(message)
   168  	}
   169  	if title != "" {
   170  		p.out.Notice(output.Emphasize(title))
   171  	}
   172  
   173  	p.analytics.EventWithLabel(constants.CatPrompt, title, "present")
   174  
   175  	var defChoice bool
   176  	if defaultChoice != nil {
   177  		defChoice = *defaultChoice
   178  	}
   179  
   180  	var resp bool
   181  	err := survey.AskOne(&Confirm{&survey.Confirm{
   182  		Message: formatMessage(strings.TrimSuffix(message, "\n"), !p.out.Config().Colored),
   183  		Default: defChoice,
   184  	}}, &resp, nil)
   185  	if err != nil {
   186  		if err == terminal.InterruptErr {
   187  			p.analytics.EventWithLabel(constants.CatPrompt, title, "interrupt")
   188  		}
   189  		return false, locale.NewInputError(err.Error())
   190  	}
   191  	p.analytics.EventWithLabel(constants.CatPrompt, title, translateConfirm(resp))
   192  
   193  	return resp, nil
   194  }
   195  
   196  func translateConfirm(confirm bool) string {
   197  	if confirm {
   198  		return "positive"
   199  	}
   200  	return "negative"
   201  }
   202  
   203  // InputSecret prompts the user for input and obfuscates the text in stdout.
   204  // Will fail if empty.
   205  func (p *Prompt) InputSecret(title, message string, flags ...ValidatorFlag) (string, error) {
   206  	if !p.isInteractive {
   207  		return "", interactiveInputError(message)
   208  	}
   209  	var response string
   210  	validators, err := processValidators(flags)
   211  	if err != nil {
   212  		return "", err
   213  	}
   214  
   215  	if title != "" {
   216  		p.out.Notice(output.Emphasize(title))
   217  	}
   218  
   219  	err = survey.AskOne(&Password{&survey.Password{
   220  		Message: formatMessage(message, !p.out.Config().Colored),
   221  	}}, &response, wrapValidators(validators))
   222  	if err != nil {
   223  		return "", locale.NewInputError(err.Error())
   224  	}
   225  	return response, nil
   226  }
   227  
   228  // wrapValidators wraps a list of validators in a wrapper function that can be run by the survey package functions
   229  func wrapValidators(validators []ValidatorFunc) ValidatorFunc {
   230  	validator := func(val interface{}) error {
   231  		for _, v := range validators {
   232  			if error := v(val); error != nil {
   233  				return error
   234  			}
   235  		}
   236  		return nil
   237  	}
   238  	return validator
   239  }
   240  
   241  // This function seems like overkill right now but the assumption is we'll have more than one built in validator
   242  func processValidators(flags []ValidatorFlag) ([]ValidatorFunc, error) {
   243  	var validators []ValidatorFunc
   244  	var err error
   245  	for flag := range flags {
   246  		switch ValidatorFlag(flag) {
   247  		case InputRequired:
   248  			validators = append(validators, inputRequired)
   249  		default:
   250  			err = locale.NewError("err_prompt_bad_flag")
   251  		}
   252  	}
   253  	return validators, err
   254  }