feat: v0.3.0d — add PR checks, iostreams, aliases, and broad enhancements
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.
This commit is contained in:
parent
7c0dcc8696
commit
113505de95
29 changed files with 3131 additions and 542 deletions
272
internal/iostreams/iostreams.go
Normal file
272
internal/iostreams/iostreams.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue