package bonfire import ( "bufio" "errors" "fmt" "io" "os" "strconv" "strings" tea "charm.land/bubbletea/v2" "charm.land/huh/v2" "golang.org/x/term" ) // ErrAborted is returned by a prompt that the person cancelled (esc or // ctrl+c on a terminal). var ErrAborted = errors.New("bonfire: prompt aborted") // errSecretNoInteraction is the error of Secret under -n when stdin is a // terminal: a secret has no default. var errSecretNoInteraction = errors.New("bonfire: a secret is needed and -n turned prompts off") // richPrompts reports whether prompts use huh and the selector: the run is // interactive and --no-ansi is not set (with --no-ansi a terminal gets the // plain line prompts). func (c *console) richPrompts() bool { return c.canPrompt() && c.globals.Color != ColorNever } // runForm runs a huh form on the console's streams. func (c *console) runForm(field huh.Field) error { err := huh.NewForm(huh.NewGroup(field)).WithInput(c.in).WithOutput(c.out).Run() if errors.Is(err, huh.ErrUserAborted) { return ErrAborted } return err } func (c *console) Ask(question, def string) (string, error) { if c.globals.NoInteraction { return def, nil } if c.richPrompts() { var answer string input := huh.NewInput().Title(question).Value(&answer) if def != "" { input = input.Placeholder(def) } if err := c.runForm(input); err != nil { return "", err } if strings.TrimSpace(answer) == "" { return def, nil } return strings.TrimSpace(answer), nil } c.writeQuestion(question, def) line, err := c.readLine() if err != nil { if err == io.EOF { return def, nil } return "", err } if line == "" { return def, nil } return line, nil } func (c *console) Confirm(question string, def bool) (bool, error) { if c.globals.NoInteraction || !c.interactive { return def, nil } if c.richPrompts() { answer := def if err := c.runForm(huh.NewConfirm().Title(question).Value(&answer)); err != nil { return false, err } return answer, nil } hint := "y/N" if def { hint = "Y/n" } line, err := c.Ask(question+" ["+hint+"]", "") if err != nil { return false, err } switch strings.ToLower(line) { case "y", "yes": return true, nil case "n", "no": return false, nil default: return def, nil } } func (c *console) Choice(question string, choices []string, def int) (string, error) { if len(choices) == 0 { return "", fmt.Errorf("bonfire: choice has no options") } if def < 0 || def >= len(choices) { def = 0 } if c.globals.NoInteraction { return choices[def], nil } if c.richPrompts() { return c.selectOne(question, choices, def) } c.Println(c.styledText(c.palette.Current.Render(symPrompt)) + " " + question) for i, choice := range choices { mark := " " if i == def { mark = "*" } c.Printf(" %s %d) %s\n", mark, i+1, choice) } line, err := c.readLine() if err != nil { if err == io.EOF { return choices[def], nil } return "", err } if line == "" { return choices[def], nil } n, convErr := strconv.Atoi(line) if convErr != nil || n < 1 || n > len(choices) { return choices[def], nil } return choices[n-1], nil } // selectOne runs the ported selector on the terminal. func (c *console) selectOne(question string, choices []string, def int) (string, error) { prog := tea.NewProgram(newSelectorModel(c.palette, question, choices, def), tea.WithInput(c.in), tea.WithOutput(c.out), tea.WithoutSignalHandler(), tea.WithColorProfile(c.profile()), ) final, err := prog.Run() if err != nil { return "", err } m, ok := final.(selectorModel) if !ok || m.cancelled || m.chosen < 0 { return "", ErrAborted } return choices[m.chosen], nil } // Secret reads a hidden value: through a password field on an interactive // terminal, through term.ReadPassword when only stdin is a terminal, and as // a plain line when stdin is not a terminal (piped input). Under -n a // terminal stdin fails with a hint, because a secret has no default. func (c *console) Secret(question string) (string, error) { if c.globals.NoInteraction { if c.inTTY { return "", WithHint(errSecretNoInteraction, "This command reads the value from a prompt or from stdin.", `printf '%s\n' "$VALUE" | {bin} -n`) } return c.readSecretLine() } if c.richPrompts() { var answer string if err := c.runForm(huh.NewInput().Title(question).EchoMode(huh.EchoModePassword).Value(&answer)); err != nil { return "", err } return answer, nil } c.writeQuestion(question, "") if f, ok := c.in.(*os.File); ok && c.inTTY { b, err := term.ReadPassword(int(f.Fd())) c.Println() if err != nil { return "", err } return string(b), nil } return c.readSecretLine() } // readSecretLine reads one line of piped input; the value is never logged. func (c *console) readSecretLine() (string, error) { line, err := c.readLine() if err != nil { if err == io.EOF { return "", nil } return "", err } return line, nil } // styledText applies the color profile to s for output written with Printf. func (c *console) styledText(s string) string { var b strings.Builder _, _ = io.WriteString(c.styled(&b), s) return b.String() } func (c *console) writeQuestion(question, def string) { suffix := "" if def != "" { suffix = " " + c.palette.Muted.Render("("+def+")") } c.Printf("%s", c.styledText(c.palette.Current.Render(symPrompt)+" "+question+suffix+" "+symArrow+" ")) } func (c *console) readLine() (string, error) { if c.in == nil { return "", io.EOF } reader, ok := c.in.(*bufio.Reader) if !ok { reader = bufio.NewReader(c.in) } line, err := reader.ReadString('\n') line = strings.TrimRight(line, "\r\n") if err != nil && err != io.EOF { return "", err } if err == io.EOF && line == "" { return "", io.EOF } return strings.TrimSpace(line), nil }