Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling.
272 lines
6.5 KiB
Go
272 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))
|
|
}
|