273 lines
6.5 KiB
Go
273 lines
6.5 KiB
Go
|
|
package iostreams
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"runtime"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"golang.org/x/term"
|
||
|
|
)
|
||
|
|
|
||
|
|
// IOStreams provides the standard streams for the CLI along with TTY detection,
|
||
|
|
// color support, pager integration, and other terminal helpers.
|
||
|
|
type IOStreams struct {
|
||
|
|
In io.Reader
|
||
|
|
Out io.Writer
|
||
|
|
ErrOut io.Writer
|
||
|
|
|
||
|
|
// Private fields for state
|
||
|
|
isStdinTTY bool
|
||
|
|
isStdoutTTY bool
|
||
|
|
isStderrTTY bool
|
||
|
|
|
||
|
|
pagerProcess *exec.Cmd
|
||
|
|
pagerPipe io.WriteCloser
|
||
|
|
originalOut io.Writer
|
||
|
|
|
||
|
|
colorScheme *ColorScheme
|
||
|
|
|
||
|
|
spinnerMu sync.Mutex
|
||
|
|
spinnerCancel chan struct{}
|
||
|
|
}
|
||
|
|
|
||
|
|
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
|
||
|
|
// with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to
|
||
|
|
// be treated as TTYs.
|
||
|
|
func New() *IOStreams {
|
||
|
|
forceTTY := os.Getenv("FGJ_FORCE_TTY") != ""
|
||
|
|
|
||
|
|
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
|
||
|
|
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
|
||
|
|
stderrTTY := forceTTY || (isTerminal(os.Stderr.Fd()))
|
||
|
|
|
||
|
|
return &IOStreams{
|
||
|
|
In: os.Stdin,
|
||
|
|
Out: os.Stdout,
|
||
|
|
ErrOut: os.Stderr,
|
||
|
|
isStdinTTY: stdinTTY,
|
||
|
|
isStdoutTTY: stdoutTTY,
|
||
|
|
isStderrTTY: stderrTTY,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test creates an IOStreams backed by bytes.Buffers, suitable for unit tests.
|
||
|
|
// All TTY flags are false.
|
||
|
|
func Test() *IOStreams {
|
||
|
|
return &IOStreams{
|
||
|
|
In: &bytes.Buffer{},
|
||
|
|
Out: &bytes.Buffer{},
|
||
|
|
ErrOut: &bytes.Buffer{},
|
||
|
|
isStdinTTY: false,
|
||
|
|
isStdoutTTY: false,
|
||
|
|
isStderrTTY: false,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsStdinTTY reports whether standard input is connected to a terminal.
|
||
|
|
func (s *IOStreams) IsStdinTTY() bool {
|
||
|
|
return s.isStdinTTY
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsStdoutTTY reports whether standard output is connected to a terminal.
|
||
|
|
func (s *IOStreams) IsStdoutTTY() bool {
|
||
|
|
return s.isStdoutTTY
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsStderrTTY reports whether standard error is connected to a terminal.
|
||
|
|
func (s *IOStreams) IsStderrTTY() bool {
|
||
|
|
return s.isStderrTTY
|
||
|
|
}
|
||
|
|
|
||
|
|
// TerminalWidth returns the width of the terminal connected to stdout. If stdout
|
||
|
|
// is not a terminal, it returns 80.
|
||
|
|
func (s *IOStreams) TerminalWidth() int {
|
||
|
|
if !s.isStdoutTTY {
|
||
|
|
return 80
|
||
|
|
}
|
||
|
|
if f, ok := s.Out.(*os.File); ok {
|
||
|
|
w, _, err := term.GetSize(int(f.Fd()))
|
||
|
|
if err == nil && w > 0 {
|
||
|
|
return w
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 80
|
||
|
|
}
|
||
|
|
|
||
|
|
// ColorEnabled returns true when color output should be used. Color is enabled
|
||
|
|
// when stdout is a TTY and the NO_COLOR environment variable is not set.
|
||
|
|
func (s *IOStreams) ColorEnabled() bool {
|
||
|
|
if os.Getenv("NO_COLOR") != "" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return s.isStdoutTTY
|
||
|
|
}
|
||
|
|
|
||
|
|
// ColorScheme returns a lazily-initialized ColorScheme that respects the current
|
||
|
|
// color settings.
|
||
|
|
func (s *IOStreams) ColorScheme() *ColorScheme {
|
||
|
|
if s.colorScheme == nil {
|
||
|
|
s.colorScheme = NewColorScheme(s.ColorEnabled())
|
||
|
|
}
|
||
|
|
return s.colorScheme
|
||
|
|
}
|
||
|
|
|
||
|
|
// StartPager starts an external pager process and redirects Out to its stdin.
|
||
|
|
// It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not
|
||
|
|
// already set, it is set to "FRX" for a good default experience.
|
||
|
|
func (s *IOStreams) StartPager() error {
|
||
|
|
if !s.isStdoutTTY {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
pagerCmd := os.Getenv("FGJ_PAGER")
|
||
|
|
if pagerCmd == "" {
|
||
|
|
pagerCmd = os.Getenv("PAGER")
|
||
|
|
}
|
||
|
|
if pagerCmd == "" {
|
||
|
|
pagerCmd = "less"
|
||
|
|
}
|
||
|
|
|
||
|
|
if os.Getenv("LESS") == "" {
|
||
|
|
os.Setenv("LESS", "FRX")
|
||
|
|
}
|
||
|
|
|
||
|
|
parts := strings.Fields(pagerCmd)
|
||
|
|
//nolint:gosec // pager command is user-configured
|
||
|
|
cmd := exec.Command(parts[0], parts[1:]...)
|
||
|
|
cmd.Stdout = s.Out
|
||
|
|
cmd.Stderr = s.ErrOut
|
||
|
|
|
||
|
|
pipe, err := cmd.StdinPipe()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("creating pager pipe: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := cmd.Start(); err != nil {
|
||
|
|
return fmt.Errorf("starting pager: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.pagerProcess = cmd
|
||
|
|
s.pagerPipe = pipe
|
||
|
|
s.originalOut = s.Out
|
||
|
|
s.Out = pipe
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// StopPager closes the pager's stdin pipe and waits for the process to exit.
|
||
|
|
// It restores Out to the original writer.
|
||
|
|
func (s *IOStreams) StopPager() {
|
||
|
|
if s.pagerPipe == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
_ = s.pagerPipe.Close()
|
||
|
|
_ = s.pagerProcess.Wait()
|
||
|
|
|
||
|
|
s.Out = s.originalOut
|
||
|
|
s.pagerPipe = nil
|
||
|
|
s.pagerProcess = nil
|
||
|
|
s.originalOut = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// spinnerFrames are the Braille-based animation frames for the spinner.
|
||
|
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||
|
|
|
||
|
|
// StartSpinner shows an animated spinner with the given label on stderr. It only
|
||
|
|
// runs when stderr is a TTY. Call StopSpinner to halt it.
|
||
|
|
func (s *IOStreams) StartSpinner(label string) {
|
||
|
|
if !s.isStderrTTY {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
s.spinnerMu.Lock()
|
||
|
|
defer s.spinnerMu.Unlock()
|
||
|
|
|
||
|
|
// Stop any existing spinner first.
|
||
|
|
if s.spinnerCancel != nil {
|
||
|
|
close(s.spinnerCancel)
|
||
|
|
s.spinnerCancel = nil
|
||
|
|
}
|
||
|
|
|
||
|
|
cancel := make(chan struct{})
|
||
|
|
s.spinnerCancel = cancel
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
ticker := time.NewTicker(80 * time.Millisecond)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
i := 0
|
||
|
|
for {
|
||
|
|
select {
|
||
|
|
case <-cancel:
|
||
|
|
// Clear the spinner line.
|
||
|
|
fmt.Fprintf(s.ErrOut, "\r\033[K")
|
||
|
|
return
|
||
|
|
case <-ticker.C:
|
||
|
|
frame := spinnerFrames[i%len(spinnerFrames)]
|
||
|
|
fmt.Fprintf(s.ErrOut, "\r%s %s", frame, label)
|
||
|
|
i++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
// StopSpinner halts the spinner and clears the line on stderr.
|
||
|
|
func (s *IOStreams) StopSpinner() {
|
||
|
|
s.spinnerMu.Lock()
|
||
|
|
defer s.spinnerMu.Unlock()
|
||
|
|
|
||
|
|
if s.spinnerCancel != nil {
|
||
|
|
close(s.spinnerCancel)
|
||
|
|
s.spinnerCancel = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// OpenInBrowser opens the given URL in the user's default browser.
|
||
|
|
func (s *IOStreams) OpenInBrowser(url string) error {
|
||
|
|
var cmd *exec.Cmd
|
||
|
|
switch runtime.GOOS {
|
||
|
|
case "darwin":
|
||
|
|
cmd = exec.Command("open", url)
|
||
|
|
case "windows":
|
||
|
|
cmd = exec.Command("cmd", "/c", "start", url)
|
||
|
|
default: // linux, freebsd, etc.
|
||
|
|
cmd = exec.Command("xdg-open", url)
|
||
|
|
}
|
||
|
|
return cmd.Start()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ConfirmAction prompts the user with a yes/no question and returns their
|
||
|
|
// answer. It returns an error if stdin is not a TTY (non-interactive).
|
||
|
|
func (s *IOStreams) ConfirmAction(prompt string) (bool, error) {
|
||
|
|
if !s.isStdinTTY {
|
||
|
|
return false, fmt.Errorf("cannot prompt for confirmation: not an interactive terminal")
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(s.ErrOut, "%s [y/N]: ", prompt)
|
||
|
|
|
||
|
|
var response string
|
||
|
|
if _, err := fmt.Fscan(s.In, &response); err != nil {
|
||
|
|
return false, fmt.Errorf("reading response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
response = strings.ToLower(strings.TrimSpace(response))
|
||
|
|
return response == "y" || response == "yes", nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewTablePrinter creates a TablePrinter that writes to this IOStreams' output.
|
||
|
|
func (s *IOStreams) NewTablePrinter() *TablePrinter {
|
||
|
|
return NewTablePrinter(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
// isTerminal reports whether the given file descriptor is a terminal.
|
||
|
|
func isTerminal(fd uintptr) bool {
|
||
|
|
return term.IsTerminal(int(fd))
|
||
|
|
}
|