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 FJ_FORCE_TTY=1 (or legacy FGJ_FORCE_TTY=1) // forces all streams to be treated as TTYs. func New() *IOStreams { forceTTY := os.Getenv("FJ_FORCE_TTY") != "" || 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 FJ_PAGER (or legacy 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("FJ_PAGER") if pagerCmd == "" { 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)) }