cli/azd/pkg/input/console.go (787 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package input import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "log" "os" "os/signal" "runtime" "slices" "strconv" "sync" "syscall" "time" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" tm "github.com/buger/goterm" "github.com/mattn/go-isatty" "github.com/nathan-fiscaletti/consolesize-go" "github.com/theckman/yacspin" "go.uber.org/atomic" ) type SpinnerUxType int const ( Step SpinnerUxType = iota StepDone StepFailed StepWarning StepSkipped ) // A shim to allow a single Console construction in the application. // To be removed once formatter and Console's responsibilities are reconciled type ConsoleShim interface { // True if the console was instantiated with no format options. IsUnformatted() bool // Gets the underlying formatter used by the console GetFormatter() output.Formatter } // ShowPreviewerOptions provide the settings to start a console previewer. type ShowPreviewerOptions struct { Prefix string MaxLineCount int Title string } type PromptDialog struct { Title string Description string Prompts []PromptDialogItem } type PromptDialogItem struct { ID string Kind string DisplayName string Description *string DefaultValue *string Required bool Choices []PromptDialogChoice } type PromptDialogChoice struct { Value string Description string } type Console interface { // Prints out a message to the underlying console write Message(ctx context.Context, message string) // Prints out a message following a contract ux item MessageUxItem(ctx context.Context, item ux.UxItem) WarnForFeature(ctx context.Context, id alpha.FeatureId) // Prints progress spinner with the given title. // If a previous spinner is running, the title is updated. ShowSpinner(ctx context.Context, title string, format SpinnerUxType) // Stop the current spinner from the console and change the spinner bar for the lastMessage // Set lastMessage to empty string to clear the spinner message instead of a displaying a last message // If there is no spinner running, this is a no-op function StopSpinner(ctx context.Context, lastMessage string, format SpinnerUxType) // Preview mode brings an embedded console within the current session. // Use nil for options to use defaults. // Use the returned io.Writer to produce the output within the previewer ShowPreviewer(ctx context.Context, options *ShowPreviewerOptions) io.Writer // Finalize the preview mode from console. StopPreviewer(ctx context.Context, keepLogs bool) // Determines if there is a current spinner running. IsSpinnerRunning(ctx context.Context) bool // Determines if the current spinner is an interactive spinner, where messages are updated periodically. // If false, the spinner is non-interactive, which means messages are rendered as a new console message on each // call to ShowSpinner, even when the title is unchanged. IsSpinnerInteractive() bool SupportsPromptDialog() bool PromptDialog(ctx context.Context, dialog PromptDialog) (map[string]any, error) // Prompts the user for a single value Prompt(ctx context.Context, options ConsoleOptions) (string, error) // PromptFs prompts the user for a filesystem path or directory. PromptFs(ctx context.Context, options ConsoleOptions, fsOptions FsOptions) (string, error) // Prompts the user to select a single value from a set of values Select(ctx context.Context, options ConsoleOptions) (int, error) // Prompts the user to select zero or more values from a set of values MultiSelect(ctx context.Context, options ConsoleOptions) ([]string, error) // Prompts the user to confirm an operation Confirm(ctx context.Context, options ConsoleOptions) (bool, error) // block terminal until the next enter WaitForEnter() // Writes a new line to the writer if there if the last two characters written are not '\n' EnsureBlankLine(ctx context.Context) // Sets the underlying writer for the console SetWriter(writer io.Writer) // Gets the underlying writer for the console GetWriter() io.Writer // Gets the standard input, output and error stream Handles() ConsoleHandles ConsoleShim } type AskerConsole struct { asker Asker handles ConsoleHandles // the writer the console was constructed with, and what we reset to when SetWriter(nil) is called. defaultWriter io.Writer // the writer which output is written to. writer io.Writer formatter output.Formatter // isTerminal controls whether terminal-style input/output will be used. // // When isTerminal is false, the following notable behaviors apply: // - Spinner progress will be written as standard newline messages. // - Prompting assumes a non-terminal environment, where output written and input received are machine-friendly text, // stripped of formatting characters. isTerminal bool noPrompt bool // when non nil, use this client instead of prompting ourselves on the console. promptClient *externalPromptClient showProgressMu sync.Mutex // ensures atomicity when swapping the current progress renderer (spinner or previewer) spinner *yacspin.Spinner spinnerLineMu sync.Mutex // secures spinnerCurrentTitle and the line of spinner text spinnerTerminalMode yacspin.TerminalMode spinnerCurrentTitle string previewer *progressLog currentIndent *atomic.String // consoleWidth is the width of the underlying console window. The value is updated as the window resized. Nil when // isTerminal is false. consoleWidth *atomic.Int32 // holds the last 2 bytes written by message or messageUX. This is used to detect when there is already an empty // line (\n\n) last2Byte [2]byte } type ConsoleOptions struct { Message string Help string Options []string // OptionDetails is an optional field that can be used to provide additional information about the options. OptionDetails []string DefaultValue any // Prompt-only options IsPassword bool } type ConsoleHandles struct { Stdin io.Reader Stdout io.Writer Stderr io.Writer } // Sets the underlying writer for output the console or // if writer is nil, sets it back to the default writer. func (c *AskerConsole) SetWriter(writer io.Writer) { if writer == nil { writer = c.defaultWriter } c.writer = writer } func (c *AskerConsole) GetFormatter() output.Formatter { return c.formatter } func (c *AskerConsole) IsUnformatted() bool { return c.formatter == nil || c.formatter.Kind() == output.NoneFormat } // Prints out a message to the underlying console write func (c *AskerConsole) Message(ctx context.Context, message string) { // Disable output when formatting is enabled if c.formatter != nil && c.formatter.Kind() == output.JsonFormat { // we call json.Marshal directly, because the formatter marshalls using indentation, and we would prefer // these objects be written on a single line. jsonMessage, err := json.Marshal(output.EventForMessage(message)) if err != nil { panic(fmt.Sprintf("Message: unexpected error during marshaling for a valid object: %v", err)) } fmt.Fprintln(c.writer, string(jsonMessage)) } else if c.formatter != nil { c.println(ctx, message) } else { log.Println(message) } // Adding "\n" b/c calling Fprintln is adding one new line at the end to the msg c.updateLastBytes(message + "\n") } func (c *AskerConsole) updateLastBytes(msg string) { msgLen := len(msg) if msgLen == 0 { return } if msgLen < 2 { c.last2Byte[0] = c.last2Byte[1] c.last2Byte[1] = msg[msgLen-1] return } c.last2Byte[0] = msg[msgLen-2] c.last2Byte[1] = msg[msgLen-1] } func (c *AskerConsole) WarnForFeature(ctx context.Context, key alpha.FeatureId) { if shouldWarn() { c.MessageUxItem(ctx, &ux.MultilineMessage{ Lines: []string{ "", output.WithWarningFormat("WARNING: Feature '%s' is in alpha stage.", string(key)), fmt.Sprintf("To learn more about alpha features and their support, visit %s.", output.WithLinkFormat("https://aka.ms/azd-feature-stages")), "", }, }) } } // shouldWarn returns true if a warning should be emitted when using a given alpha feature. func shouldWarn() bool { noAlphaWarnings, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_NO_ALPHA_WARNINGS")) return err != nil || !noAlphaWarnings } func (c *AskerConsole) MessageUxItem(ctx context.Context, item ux.UxItem) { if c.formatter != nil && c.formatter.Kind() == output.JsonFormat { // no need to check the spinner for json format, as the spinner won't start when using json format // instead, there would be a message about starting spinner json, _ := json.Marshal(item) fmt.Fprintln(c.writer, string(json)) return } msg := item.ToString(c.currentIndent.Load()) c.println(ctx, msg) // Adding "\n" b/c calling Fprintln is adding one new line at the end to the msg c.updateLastBytes(msg + "\n") } func (c *AskerConsole) println(ctx context.Context, msg string) { if c.IsSpinnerInteractive() && c.spinner.Status() == yacspin.SpinnerRunning { c.StopSpinner(ctx, "", Step) // default non-format fmt.Fprintln(c.writer, msg) _ = c.spinner.Start() } else { fmt.Fprintln(c.writer, msg) } } func defaultShowPreviewerOptions() *ShowPreviewerOptions { return &ShowPreviewerOptions{ MaxLineCount: 5, } } func (c *AskerConsole) ShowPreviewer(ctx context.Context, options *ShowPreviewerOptions) io.Writer { c.showProgressMu.Lock() defer c.showProgressMu.Unlock() // Pause any active spinner currentMsg := c.spinnerCurrentTitle _ = c.spinner.Pause() if options == nil { options = defaultShowPreviewerOptions() } c.previewer = NewProgressLog(options.MaxLineCount, options.Prefix, options.Title, c.currentIndent.Load()+currentMsg) c.previewer.Start() c.writer = c.previewer return &consolePreviewerWriter{ previewer: &c.previewer, } } func (c *AskerConsole) StopPreviewer(ctx context.Context, keepLogs bool) { c.previewer.Stop(keepLogs) c.previewer = nil c.writer = c.defaultWriter _ = c.spinner.Unpause() } // truncationDots is the text we use to indicate that text has been truncated. const truncationDots = "..." // The line of text for the spinner, displayed in the format of: <prefix><spinner> <message> type spinnerLine struct { // The prefix before the spinner. Prefix string // Charset that is used to animate the spinner. CharSet []string // The message to be displayed. Message string } func (c *AskerConsole) spinnerLine(title string, indent string) spinnerLine { if !c.isTerminal { return spinnerLine{ Prefix: indent, CharSet: spinnerNoTerminalCharSet, Message: title, } } spinnerLen := len(indent) + len(spinnerCharSet[0]) + 1 // adding one for the empty space before the message width := int(c.consoleWidth.Load()) switch { case width <= 3: // show number of dots up to 3 return spinnerLine{ CharSet: spinnerShortCharSet[:width], } case width <= spinnerLen+len(truncationDots): // show number of dots return spinnerLine{ CharSet: spinnerShortCharSet, } case width <= spinnerLen+len(title): // truncate title return spinnerLine{ Prefix: indent, CharSet: spinnerCharSet, Message: title[:width-spinnerLen-len(truncationDots)] + truncationDots, } default: return spinnerLine{ Prefix: indent, CharSet: spinnerCharSet, Message: title, } } } func (c *AskerConsole) ShowSpinner(ctx context.Context, title string, format SpinnerUxType) { c.showProgressMu.Lock() defer c.showProgressMu.Unlock() if c.formatter != nil && c.formatter.Kind() == output.JsonFormat { // Spinner is disabled when using json format. return } if c.previewer != nil { // spinner is not compatible with previewer. c.previewer.Header(c.currentIndent.Load() + title) return } c.spinnerLineMu.Lock() c.spinnerCurrentTitle = title indentPrefix := c.getIndent() line := c.spinnerLine(title, indentPrefix) _ = c.spinner.Pause() c.spinner.Message(line.Message) _ = c.spinner.CharSet(line.CharSet) c.spinner.Prefix(line.Prefix) _ = c.spinner.Unpause() if c.spinner.Status() == yacspin.SpinnerStopped { // While it is indeed safe to call Start regardless of whether the spinner is running, // calling Start may result in an additional line of output being written in non-tty scenarios _ = c.spinner.Start() } c.spinnerLineMu.Unlock() } // spinnerTerminalMode determines the appropriate terminal mode. func spinnerTerminalMode(isTerminal bool) yacspin.TerminalMode { nonInteractiveMode := yacspin.ForceNoTTYMode | yacspin.ForceDumbTerminalMode if !isTerminal { return nonInteractiveMode } termMode := yacspin.ForceTTYMode if os.Getenv("TERM") == "dumb" { termMode |= yacspin.ForceDumbTerminalMode } else { termMode |= yacspin.ForceSmartTerminalMode } return termMode } var spinnerCharSet []string = []string{ "| |", "|= |", "|== |", "|=== |", "|==== |", "|===== |", "|====== |", "|=======|", "| ======|", "| =====|", "| ====|", "| ===|", "| ==|", "| =|", } var spinnerShortCharSet []string = []string{".", "..", "..."} var spinnerNoTerminalCharSet []string = []string{""} func setIndentation(spaces int) string { bytes := make([]byte, spaces) for i := range bytes { bytes[i] = byte(' ') } return string(bytes) } func (c *AskerConsole) getIndent() string { requiredSize := 2 if requiredSize != len(c.currentIndent.Load()) { c.currentIndent.Store(setIndentation(requiredSize)) } return c.currentIndent.Load() } func (c *AskerConsole) StopSpinner(ctx context.Context, lastMessage string, format SpinnerUxType) { if c.formatter != nil && c.formatter.Kind() == output.JsonFormat { // Spinner is disabled when using json format. return } // Do nothing when it is already stopped if c.spinner.Status() == yacspin.SpinnerStopped { return } c.spinnerLineMu.Lock() c.spinnerCurrentTitle = "" // Update style according to MessageUxType if lastMessage != "" { lastMessage = c.getStopChar(format) + " " + lastMessage } _ = c.spinner.Stop() if lastMessage != "" { // Avoid using StopMessage() as it may result in an extra Message line print in non-tty scenarios fmt.Fprintln(c.writer, lastMessage) } c.spinnerLineMu.Unlock() } func (c *AskerConsole) IsSpinnerRunning(ctx context.Context) bool { return c.spinner.Status() != yacspin.SpinnerStopped } func (c *AskerConsole) IsSpinnerInteractive() bool { return c.spinnerTerminalMode&yacspin.ForceTTYMode > 0 } var donePrefix string = output.WithSuccessFormat("(✓) Done:") func (c *AskerConsole) getStopChar(format SpinnerUxType) string { var stopChar string switch format { case StepDone: stopChar = donePrefix case StepFailed: stopChar = output.WithErrorFormat("(x) Failed:") case StepWarning: stopChar = output.WithWarningFormat("(!) Warning:") case StepSkipped: stopChar = output.WithGrayFormat("(-) Skipped:") } return fmt.Sprintf("%s%s", c.getIndent(), stopChar) } func promptFromOptions(options ConsoleOptions) survey.Prompt { if options.IsPassword { // different than survey.Input, survey.Password doest not reset the line before rendering the question // see password implementation: https://github.com/AlecAivazis/survey/blob/master/password.go#L51 // and input: https://github.com/AlecAivazis/survey/blob/master/input.go#L141 // by calling .Render(), the line is reset, cleaning any current message or spinner. tm.Print(tm.ResetLine("")) tm.Flush() return &survey.Password{ Message: options.Message, Help: options.Help, } } var defaultValue string if value, ok := options.DefaultValue.(string); ok { defaultValue = value } return &survey.Input{ Message: options.Message, Default: defaultValue, Help: options.Help, } } // afterIoSentinel is a sentinel value used after Input/Output operations as the state for the last 2-bytes written. // For example, after running Prompt or Confirm, the last characters on the terminal should be any char (represented by the // 0 in the sentinel), followed by a new line. const afterIoSentinel = "0\n" func (c *AskerConsole) SupportsPromptDialog() bool { return c.promptClient != nil } // PromptDialog prompts for multiple values using a single dialog. When successful, it returns a map of prompt IDs to their // values. func (c *AskerConsole) PromptDialog(ctx context.Context, dialog PromptDialog) (map[string]any, error) { request := externalPromptDialogRequest{ Title: dialog.Title, Description: dialog.Description, Prompts: make([]externalPromptDialogPrompt, len(dialog.Prompts)), } for i, prompt := range dialog.Prompts { request.Prompts[i] = externalPromptDialogPrompt{ ID: prompt.ID, Kind: prompt.Kind, DisplayName: prompt.DisplayName, Description: prompt.Description, DefaultValue: prompt.DefaultValue, Required: prompt.Required, } } resp, err := c.promptClient.PromptDialog(ctx, request) if err != nil { return nil, err } ret := make(map[string]any, len(*resp.Inputs)) for _, v := range *resp.Inputs { var unmarshalledValue any if err := json.Unmarshal(v.Value, &unmarshalledValue); err != nil { return nil, fmt.Errorf("unmarshalling value %s: %w", v.ID, err) } ret[v.ID] = unmarshalledValue } return ret, nil } // Prompts the user for a single value func (c *AskerConsole) Prompt(ctx context.Context, options ConsoleOptions) (string, error) { var response string if c.promptClient != nil { opts := promptOptions{ Type: "string", Options: promptOptionsOptions{ Message: options.Message, Help: options.Help, }, } if options.IsPassword { opts.Type = "password" } if value, ok := options.DefaultValue.(string); ok { opts.Options.DefaultValue = to.Ptr[any](value) } result, err := c.promptClient.Prompt(ctx, opts) if errors.Is(err, promptCancelledErr) { return "", terminal.InterruptErr } else if err != nil { return "", err } if err := json.Unmarshal(result, &response); err != nil { return "", fmt.Errorf("unmarshalling response: %w", err) } return response, nil } err := c.doInteraction(func(c *AskerConsole) error { return c.asker(promptFromOptions(options), &response) }) if err != nil { return response, err } c.updateLastBytes(afterIoSentinel) return response, nil } func choicesFromOptions(options ConsoleOptions) []promptChoice { choices := make([]promptChoice, len(options.Options)) for i, option := range options.Options { choices[i] = promptChoice{ Value: option, } if i < len(options.OptionDetails) && options.OptionDetails[i] != "" { choices[i].Detail = &options.OptionDetails[i] } } return choices } // Prompts the user to select from a set of values func (c *AskerConsole) Select(ctx context.Context, options ConsoleOptions) (int, error) { if c.promptClient != nil { opts := promptOptions{ Type: "select", Options: promptOptionsOptions{ Message: options.Message, Help: options.Help, Choices: to.Ptr(choicesFromOptions(options)), }, } if value, ok := options.DefaultValue.(string); ok { opts.Options.DefaultValue = to.Ptr[any](value) } result, err := c.promptClient.Prompt(ctx, opts) if errors.Is(err, promptCancelledErr) { return -1, terminal.InterruptErr } else if err != nil { return -1, err } var choice string if err := json.Unmarshal(result, &choice); err != nil { return -1, fmt.Errorf("unmarshalling response: %w", err) } res := slices.Index(options.Options, choice) if res == -1 { return -1, fmt.Errorf("invalid choice: %s", choice) } return res, nil } surveyOptions := make([]string, len(options.Options)) surveyDefault := options.DefaultValue surveyDefaultAsString, surveyDefaultIsString := surveyDefault.(string) // Modify the options and default value to include any details for i, option := range options.Options { surveyOptions[i] = option if c.IsSpinnerInteractive() && i < len(options.OptionDetails) { if options.OptionDetails[i] != "" { detailString := output.WithGrayFormat("(%s)", options.OptionDetails[i]) surveyOptions[i] += fmt.Sprintf("\n %s\n", detailString) } else { surveyOptions[i] += "\n" } if surveyDefaultIsString && surveyDefaultAsString == option { surveyDefault = surveyOptions[i] } } } survey := &survey.Select{ Message: options.Message, Options: surveyOptions, Default: surveyDefault, Help: options.Help, } var response int err := c.doInteraction(func(c *AskerConsole) error { return c.asker(survey, &response) }) if err != nil { return -1, err } c.updateLastBytes(afterIoSentinel) return response, nil } func (c *AskerConsole) MultiSelect(ctx context.Context, options ConsoleOptions) ([]string, error) { var response []string if c.promptClient != nil { opts := promptOptions{ Type: "multiSelect", Options: promptOptionsOptions{ Message: options.Message, Help: options.Help, Choices: to.Ptr(choicesFromOptions(options)), }, } if value, ok := options.DefaultValue.([]string); ok { opts.Options.DefaultValue = to.Ptr[any](value) } result, err := c.promptClient.Prompt(ctx, opts) if errors.Is(err, promptCancelledErr) { return nil, terminal.InterruptErr } else if err != nil { return nil, err } if err := json.Unmarshal(result, &response); err != nil { return nil, fmt.Errorf("unmarshalling response: %w", err) } return response, nil } surveyOptions := make([]string, len(options.Options)) surveyDefault := options.DefaultValue surveyDefaultAsArr, surveyDefaultIsArr := surveyDefault.([]string) // Modify the options and default value to include any details for i, option := range options.Options { surveyOptions[i] = option if c.IsSpinnerInteractive() && i < len(options.OptionDetails) { detailString := output.WithGrayFormat("%s", options.OptionDetails[i]) surveyOptions[i] += fmt.Sprintf("\n %s\n", detailString) } if surveyDefaultIsArr { for idx, defaultOption := range surveyDefaultAsArr { if defaultOption == option { surveyDefaultAsArr[idx] = surveyOptions[i] } } } } survey := &survey.MultiSelect{ Message: options.Message, Options: surveyOptions, Default: surveyDefault, Help: options.Help, } err := c.doInteraction(func(c *AskerConsole) error { return c.asker(survey, &response) }) if err != nil { return nil, err } return response, nil } // Prompts the user to confirm an operation func (c *AskerConsole) Confirm(ctx context.Context, options ConsoleOptions) (bool, error) { if c.promptClient != nil { opts := promptOptions{ Type: "confirm", Options: promptOptionsOptions{ Message: options.Message, Help: options.Help, }, } if value, ok := options.DefaultValue.(bool); ok { opts.Options.DefaultValue = to.Ptr[any](value) } result, err := c.promptClient.Prompt(ctx, opts) if errors.Is(err, promptCancelledErr) { return false, terminal.InterruptErr } else if err != nil { return false, err } var response string if err := json.Unmarshal(result, &response); err != nil { return false, fmt.Errorf("unmarshalling response: %w", err) } switch response { case "true": return true, nil case "false": return false, nil default: return false, fmt.Errorf("invalid response: %s", response) } } var defaultValue bool if value, ok := options.DefaultValue.(bool); ok { defaultValue = value } survey := &survey.Confirm{ Message: options.Message, Help: options.Help, Default: defaultValue, } var response bool err := c.doInteraction(func(c *AskerConsole) error { return c.asker(survey, &response) }) if err != nil { return false, err } c.updateLastBytes(afterIoSentinel) return response, nil } const c_newLine = '\n' func (c *AskerConsole) EnsureBlankLine(ctx context.Context) { if c.last2Byte[0] == c_newLine && c.last2Byte[1] == c_newLine { return } if c.last2Byte[1] != c_newLine { c.Message(ctx, "\n") return } // [1] is '\n' but [0] is not. One new line missing c.Message(ctx, "") } // wait until the next enter func (c *AskerConsole) WaitForEnter() { if c.noPrompt { return } inputScanner := bufio.NewScanner(c.handles.Stdin) if scan := inputScanner.Scan(); !scan { if err := inputScanner.Err(); err != nil { log.Printf("error while waiting for enter: %v", err) } } } // Gets the underlying writer for the console func (c *AskerConsole) GetWriter() io.Writer { return c.writer } func (c *AskerConsole) Handles() ConsoleHandles { return c.handles } // consoleWidth the number of columns in the active console window func consoleWidth() int32 { widthInt, _ := consolesize.GetConsoleSize() // Suppress G115: integer overflow conversion int -> int32 below. // Explanation: // consolesize.GetConsoleSize() returns an int, but the underlying implementation actually is a uint16 on both // Windows and unix systems. // // In practice, console width is the number of columns (text) in the active console window. // We don't ever expect this to be larger than math.MaxInt32, so we can safely cast to int32. // nolint:gosec // G115 return int32(widthInt) } func (c *AskerConsole) handleResize(width int32) { c.consoleWidth.Store(width) c.spinnerLineMu.Lock() if c.spinner.Status() == yacspin.SpinnerRunning { line := c.spinnerLine(c.spinnerCurrentTitle, c.currentIndent.Load()) c.spinner.Message(line.Message) _ = c.spinner.CharSet(line.CharSet) c.spinner.Prefix(line.Prefix) } c.spinnerLineMu.Unlock() } func watchTerminalResize(c *AskerConsole) { if runtime.GOOS == "windows" { go func() { prevWidth := consoleWidth() for { time.Sleep(time.Millisecond * 250) width := consoleWidth() if prevWidth != width { c.handleResize(width) } prevWidth = width } }() } else { // avoid taking a dependency on syscall.SIGWINCH (unix-only constant) directly const SIGWINCH = syscall.Signal(0x1c) signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, SIGWINCH) go func() { for range signalChan { c.handleResize(consoleWidth()) } }() } } func watchTerminalInterrupt(c *AskerConsole) { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, os.Interrupt) go func() { <-signalChan // unhide the cursor if applicable _ = c.spinner.Stop() os.Exit(1) }() } // Writers that back the underlying console. type Writers struct { // The writer to write output to. Output io.Writer // The writer to write spinner output to. If nil, the spinner will write to Output. Spinner io.Writer } // ExternalPromptConfiguration allows configuring the console to delegate prompts to an external service. type ExternalPromptConfiguration struct { Endpoint string Key string Transporter policy.Transporter } // Creates a new console with the specified writers, handles and formatter. When externalPromptCfg is non nil, it is used // instead of prompting on the console. func NewConsole( noPrompt bool, isTerminal bool, writers Writers, handles ConsoleHandles, formatter output.Formatter, externalPromptCfg *ExternalPromptConfiguration) Console { asker := NewAsker(noPrompt, isTerminal, handles.Stdout, handles.Stdin) c := &AskerConsole{ asker: asker, handles: handles, defaultWriter: writers.Output, writer: writers.Output, formatter: formatter, isTerminal: isTerminal, currentIndent: atomic.NewString(""), noPrompt: noPrompt, } if writers.Spinner == nil { writers.Spinner = writers.Output } if externalPromptCfg != nil { c.promptClient = newExternalPromptClient( externalPromptCfg.Endpoint, externalPromptCfg.Key, externalPromptCfg.Transporter) } spinnerConfig := yacspin.Config{ Frequency: 200 * time.Millisecond, Writer: writers.Spinner, Suffix: " ", TerminalMode: spinnerTerminalMode(isTerminal), } if isTerminal { spinnerConfig.CharSet = spinnerCharSet } else { spinnerConfig.CharSet = spinnerNoTerminalCharSet } c.spinner, _ = yacspin.New(spinnerConfig) c.spinnerTerminalMode = spinnerConfig.TerminalMode if isTerminal { c.consoleWidth = atomic.NewInt32(consoleWidth()) watchTerminalResize(c) watchTerminalInterrupt(c) } return c } // IsTerminal returns true if the given file descriptors are attached to a terminal, // taking into account of environment variables that force TTY behavior. func IsTerminal(stdoutFd uintptr, stdinFd uintptr) bool { // User override to force TTY behavior if forceTty, err := strconv.ParseBool(os.Getenv("AZD_FORCE_TTY")); err == nil { return forceTty } // By default, detect if we are running on CI and force no TTY mode if we are. // If this is affecting you locally while debugging on a CI machine, // use the override AZD_FORCE_TTY=true. if resource.IsRunningOnCI() { return false } return isatty.IsTerminal(stdoutFd) && isatty.IsTerminal(stdinFd) } func GetStepResultFormat(result error) SpinnerUxType { formatResult := StepDone if result != nil { formatResult = StepFailed } return formatResult } // Handle doing interactive calls. It checks if there's a spinner running to pause it before doing interactive actions. func (c *AskerConsole) doInteraction(promptFn func(c *AskerConsole) error) error { if c.spinner.Status() == yacspin.SpinnerRunning { _ = c.spinner.Pause() // Ensure the spinner is always resumed defer func() { _ = c.spinner.Unpause() }() } // Track total time for promptFn. // It includes the time spent in rendering the prompt (likely <1ms) // before the user has a chance to interact with the prompt. start := time.Now() defer func() { tracing.InteractTimeMs.Add(time.Since(start).Milliseconds()) }() // Execute the interactive prompt return promptFn(c) }