- Spinner, gradient progress bar and the multi-row process view ported from SSU to bubbletea v2 (input off, no signal handler, command context); boxed tables through lipgloss/table - Prompts use huh fields and a single-select port of SSU's selector with its confirmation screen on an interactive terminal; line prompts off a terminal and under --no-ansi are unchanged (piped passwords still work) - -q hides decoration but keeps data and errors; -n returns defaults and Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY runs print today's plain lines - Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed, Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted - diary's styled console lines print above a running live view
236 lines
5.7 KiB
Go
236 lines
5.7 KiB
Go
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} <command> -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
|
|
}
|