fj/internal/iostreams/iostreams.go
sid c3e8ad67ed
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
complete fgj → fj rename: env vars, config migration, docs
- Rename env vars: FGJ_HOST → FJ_HOST, FGJ_TOKEN → FJ_TOKEN,
  FGJ_FORCE_TTY → FJ_FORCE_TTY, FGJ_PAGER → FJ_PAGER,
  FGJ_BINARY_PATH → FJ_BINARY_PATH (all with legacy fallback)
- Auto-migrate ~/.config/fgj/ → ~/.config/fj/ on first run
- Update man page title, README, CHANGELOG
- Update test fixture labels from [FGJ E2E Test] to [FJ E2E Test]
2026-04-26 08:23:48 -06:00

275 lines
6.6 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 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))
}