coder/cli/cliui/prompt.go

178 lines
4.5 KiB
Go

package cliui
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"github.com/bgentry/speakeasy"
"github.com/mattn/go-isatty"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/clibase"
)
// PromptOptions supply a set of options to the prompt.
type PromptOptions struct {
Text string
Default string
Secret bool
IsConfirm bool
Validate func(string) error
}
const skipPromptFlag = "yes"
// SkipPromptOption adds a "--yes/-y" flag to the cmd that can be used to skip
// prompts.
func SkipPromptOption() clibase.Option {
return clibase.Option{
Flag: skipPromptFlag,
FlagShorthand: "y",
Description: "Bypass prompts.",
// Discard
Value: clibase.BoolOf(new(bool)),
}
}
const (
ConfirmYes = "yes"
ConfirmNo = "no"
)
// Prompt asks the user for input.
func Prompt(inv *clibase.Invocation, opts PromptOptions) (string, error) {
// If the cmd has a "yes" flag for skipping confirm prompts, honor it.
// If it's not a "Confirm" prompt, then don't skip. As the default value of
// "yes" makes no sense.
if opts.IsConfirm && inv.ParsedFlags().Lookup(skipPromptFlag) != nil {
if skip, _ := inv.ParsedFlags().GetBool(skipPromptFlag); skip {
return ConfirmYes, nil
}
}
_, _ = fmt.Fprint(inv.Stdout, Styles.FocusedPrompt.String()+opts.Text+" ")
if opts.IsConfirm {
if len(opts.Default) == 0 {
opts.Default = ConfirmYes
}
renderedYes := Styles.Placeholder.Render(ConfirmYes)
renderedNo := Styles.Placeholder.Render(ConfirmNo)
if opts.Default == ConfirmYes {
renderedYes = Styles.Bold.Render(ConfirmYes)
} else {
renderedNo = Styles.Bold.Render(ConfirmNo)
}
_, _ = fmt.Fprint(inv.Stdout, Styles.Placeholder.Render("("+renderedYes+Styles.Placeholder.Render("/"+renderedNo+Styles.Placeholder.Render(") "))))
} else if opts.Default != "" {
_, _ = fmt.Fprint(inv.Stdout, Styles.Placeholder.Render("("+opts.Default+") "))
}
interrupt := make(chan os.Signal, 1)
if inv.Stdin == nil {
panic("inv.Stdin is nil")
}
errCh := make(chan error, 1)
lineCh := make(chan string)
go func() {
var line string
var err error
inFile, isInputFile := inv.Stdin.(*os.File)
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
// we don't install a signal handler here because speakeasy has its own
line, err = speakeasy.Ask("")
} else {
signal.Notify(interrupt, os.Interrupt)
defer signal.Stop(interrupt)
reader := bufio.NewReader(inv.Stdin)
line, err = reader.ReadString('\n')
// Check if the first line beings with JSON object or array chars.
// This enables multiline JSON to be pasted into an input, and have
// it parse properly.
if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) {
line, err = promptJSON(reader, line)
}
}
if err != nil {
errCh <- err
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
line = opts.Default
}
select {
case <-inv.Context().Done():
case lineCh <- line:
}
}()
select {
case err := <-errCh:
return "", err
case line := <-lineCh:
if opts.IsConfirm && line != "yes" && line != "y" {
return line, xerrors.Errorf("got %q: %w", line, Canceled)
}
if opts.Validate != nil {
err := opts.Validate(line)
if err != nil {
_, _ = fmt.Fprintln(inv.Stdout, defaultStyles.Error.Render(err.Error()))
return Prompt(inv, opts)
}
}
return line, nil
case <-inv.Context().Done():
return "", inv.Context().Err()
case <-interrupt:
// Print a newline so that any further output starts properly on a new line.
_, _ = fmt.Fprintln(inv.Stdout)
return "", Canceled
}
}
func promptJSON(reader *bufio.Reader, line string) (string, error) {
var data bytes.Buffer
for {
_, _ = data.WriteString(line)
var rawMessage json.RawMessage
err := json.Unmarshal(data.Bytes(), &rawMessage)
if err != nil {
if err.Error() != "unexpected end of JSON input" {
// If a real syntax error occurs in JSON,
// we want to return that partial line to the user.
err = nil
line = data.String()
break
}
// Read line-by-line. We can't use a JSON decoder
// here because it doesn't work by newline, so
// reads will block.
line, err = reader.ReadString('\n')
if err != nil {
break
}
continue
}
// Compacting the JSON makes it easier for parsing and testing.
rawJSON := data.Bytes()
data.Reset()
err = json.Compact(&data, rawJSON)
if err != nil {
return line, xerrors.Errorf("compact json: %w", err)
}
return data.String(), nil
}
return line, nil
}