fix(cli): make stdio routing injectable
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
b1afac56bb
commit
fcf5f9cfd5
14 changed files with 176 additions and 76 deletions
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"forge.lthn.ai/core/go-i18n"
|
||||||
"forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go-log"
|
||||||
"dappco.re/go/core"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -175,13 +175,13 @@ PowerShell:
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "bash":
|
case "bash":
|
||||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
_ = cmd.Root().GenBashCompletion(stdoutWriter())
|
||||||
case "zsh":
|
case "zsh":
|
||||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
_ = cmd.Root().GenZshCompletion(stdoutWriter())
|
||||||
case "fish":
|
case "fish":
|
||||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
_ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
|
||||||
case "powershell":
|
case "powershell":
|
||||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
_ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,10 @@ func DetectMode() Mode {
|
||||||
// cli.Success("interactive output enabled")
|
// cli.Success("interactive output enabled")
|
||||||
// }
|
// }
|
||||||
func IsTTY() bool {
|
func IsTTY() bool {
|
||||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
if f, ok := stdoutWriter().(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStdinTTY returns true if stdin is a terminal.
|
// IsStdinTTY returns true if stdin is a terminal.
|
||||||
|
|
@ -69,7 +72,10 @@ func IsTTY() bool {
|
||||||
// cli.Warn("input is piped")
|
// cli.Warn("input is piped")
|
||||||
// }
|
// }
|
||||||
func IsStdinTTY() bool {
|
func IsStdinTTY() bool {
|
||||||
return term.IsTerminal(int(os.Stdin.Fd()))
|
if f, ok := stdinReader().(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStderrTTY returns true if stderr is a terminal.
|
// IsStderrTTY returns true if stderr is a terminal.
|
||||||
|
|
@ -78,5 +84,8 @@ func IsStdinTTY() bool {
|
||||||
// cli.Progress("load", 1, 3, "config")
|
// cli.Progress("load", 1, 3, "config")
|
||||||
// }
|
// }
|
||||||
func IsStderrTTY() bool {
|
func IsStderrTTY() bool {
|
||||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
if f, ok := stderrWriter().(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ func Exit(code int, err error) error {
|
||||||
func Fatal(err error) {
|
func Fatal(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
LogError("Fatal error", "err", err)
|
LogError("Fatal error", "err", err)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +131,7 @@ func Fatal(err error) {
|
||||||
func Fatalf(format string, args ...any) {
|
func Fatalf(format string, args ...any) {
|
||||||
msg := fmt.Sprintf(format, args...)
|
msg := fmt.Sprintf(format, args...)
|
||||||
LogError("Fatal error", "msg", msg)
|
LogError("Fatal error", "msg", msg)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,7 +147,7 @@ func FatalWrap(err error, msg string) {
|
||||||
}
|
}
|
||||||
LogError("Fatal error", "msg", msg, "err", err)
|
LogError("Fatal error", "msg", msg, "err", err)
|
||||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +164,6 @@ func FatalWrapVerb(err error, verb, subject string) {
|
||||||
msg := i18n.ActionFailed(verb, subject)
|
msg := i18n.ActionFailed(verb, subject)
|
||||||
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
||||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ func NewFrame(variant string) *Frame {
|
||||||
variant: variant,
|
variant: variant,
|
||||||
layout: Layout(variant),
|
layout: Layout(variant),
|
||||||
models: make(map[Region]Model),
|
models: make(map[Region]Model),
|
||||||
out: os.Stderr,
|
out: stderrWriter(),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
focused: RegionContent,
|
focused: RegionContent,
|
||||||
keyMap: DefaultKeyMap(),
|
keyMap: DefaultKeyMap(),
|
||||||
|
|
@ -467,7 +467,7 @@ func (f *Frame) runLive() {
|
||||||
opts := []tea.ProgramOption{
|
opts := []tea.ProgramOption{
|
||||||
tea.WithAltScreen(),
|
tea.WithAltScreen(),
|
||||||
}
|
}
|
||||||
if f.out != os.Stdout {
|
if f.out != stdoutWriter() {
|
||||||
opts = append(opts, tea.WithOutput(f.out))
|
opts = append(opts, tea.WithOutput(f.out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
68
pkg/cli/io.go
Normal file
68
pkg/cli/io.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
stdin io.Reader = os.Stdin
|
||||||
|
|
||||||
|
stdoutOverride io.Writer
|
||||||
|
stderrOverride io.Writer
|
||||||
|
|
||||||
|
ioMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetStdin overrides the default stdin reader for testing.
|
||||||
|
// Pass nil to restore the real os.Stdin reader.
|
||||||
|
func SetStdin(r io.Reader) {
|
||||||
|
ioMu.Lock()
|
||||||
|
defer ioMu.Unlock()
|
||||||
|
if r == nil {
|
||||||
|
stdin = os.Stdin
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdin = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStdout overrides the default stdout writer.
|
||||||
|
// Pass nil to restore writes to os.Stdout.
|
||||||
|
func SetStdout(w io.Writer) {
|
||||||
|
ioMu.Lock()
|
||||||
|
defer ioMu.Unlock()
|
||||||
|
stdoutOverride = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStderr overrides the default stderr writer.
|
||||||
|
// Pass nil to restore writes to os.Stderr.
|
||||||
|
func SetStderr(w io.Writer) {
|
||||||
|
ioMu.Lock()
|
||||||
|
defer ioMu.Unlock()
|
||||||
|
stderrOverride = w
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdinReader() io.Reader {
|
||||||
|
ioMu.RLock()
|
||||||
|
defer ioMu.RUnlock()
|
||||||
|
return stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdoutWriter() io.Writer {
|
||||||
|
ioMu.RLock()
|
||||||
|
defer ioMu.RUnlock()
|
||||||
|
if stdoutOverride != nil {
|
||||||
|
return stdoutOverride
|
||||||
|
}
|
||||||
|
return os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
func stderrWriter() io.Writer {
|
||||||
|
ioMu.RLock()
|
||||||
|
defer ioMu.RUnlock()
|
||||||
|
if stderrOverride != nil {
|
||||||
|
return stderrOverride
|
||||||
|
}
|
||||||
|
return os.Stderr
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"forge.lthn.ai/core/go-i18n"
|
||||||
|
|
@ -10,35 +9,35 @@ import (
|
||||||
|
|
||||||
// Blank prints an empty line.
|
// Blank prints an empty line.
|
||||||
func Blank() {
|
func Blank() {
|
||||||
fmt.Println()
|
fmt.Fprintln(stdoutWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echo translates a key via i18n.T and prints with newline.
|
// Echo translates a key via i18n.T and prints with newline.
|
||||||
// No automatic styling - use Success/Error/Warn/Info for styled output.
|
// No automatic styling - use Success/Error/Warn/Info for styled output.
|
||||||
func Echo(key string, args ...any) {
|
func Echo(key string, args ...any) {
|
||||||
fmt.Println(compileGlyphs(i18n.T(key, args...)))
|
fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print outputs formatted text (no newline).
|
// Print outputs formatted text (no newline).
|
||||||
// Glyph shortcodes like :check: are converted.
|
// Glyph shortcodes like :check: are converted.
|
||||||
func Print(format string, args ...any) {
|
func Print(format string, args ...any) {
|
||||||
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
|
fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Println outputs formatted text with newline.
|
// Println outputs formatted text with newline.
|
||||||
// Glyph shortcodes like :check: are converted.
|
// Glyph shortcodes like :check: are converted.
|
||||||
func Println(format string, args ...any) {
|
func Println(format string, args ...any) {
|
||||||
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
|
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text prints arguments like fmt.Println, but handling glyphs.
|
// Text prints arguments like fmt.Println, but handling glyphs.
|
||||||
func Text(args ...any) {
|
func Text(args ...any) {
|
||||||
fmt.Println(compileGlyphs(fmt.Sprint(args...)))
|
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success prints a success message with checkmark (green).
|
// Success prints a success message with checkmark (green).
|
||||||
func Success(msg string) {
|
func Success(msg string) {
|
||||||
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + compileGlyphs(msg)))
|
fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successf prints a formatted success message.
|
// Successf prints a formatted success message.
|
||||||
|
|
@ -49,7 +48,7 @@ func Successf(format string, args ...any) {
|
||||||
// Error prints an error message with cross (red) to stderr and logs it.
|
// Error prints an error message with cross (red) to stderr and logs it.
|
||||||
func Error(msg string) {
|
func Error(msg string) {
|
||||||
LogError(msg)
|
LogError(msg)
|
||||||
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
|
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf prints a formatted error message to stderr and logs it.
|
// Errorf prints a formatted error message to stderr and logs it.
|
||||||
|
|
@ -86,7 +85,7 @@ func ErrorWrapAction(err error, verb string) {
|
||||||
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
|
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
|
||||||
func Warn(msg string) {
|
func Warn(msg string) {
|
||||||
LogWarn(msg)
|
LogWarn(msg)
|
||||||
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
|
fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warnf prints a formatted warning message to stderr and logs it.
|
// Warnf prints a formatted warning message to stderr and logs it.
|
||||||
|
|
@ -96,7 +95,7 @@ func Warnf(format string, args ...any) {
|
||||||
|
|
||||||
// Info prints an info message with info symbol (blue).
|
// Info prints an info message with info symbol (blue).
|
||||||
func Info(msg string) {
|
func Info(msg string) {
|
||||||
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + compileGlyphs(msg)))
|
fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof prints a formatted info message.
|
// Infof prints a formatted info message.
|
||||||
|
|
@ -106,7 +105,7 @@ func Infof(format string, args ...any) {
|
||||||
|
|
||||||
// Dim prints dimmed text.
|
// Dim prints dimmed text.
|
||||||
func Dim(msg string) {
|
func Dim(msg string) {
|
||||||
fmt.Println(DimStyle.Render(compileGlyphs(msg)))
|
fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress prints a progress indicator that overwrites the current line.
|
// Progress prints a progress indicator that overwrites the current line.
|
||||||
|
|
@ -114,20 +113,20 @@ func Dim(msg string) {
|
||||||
func Progress(verb string, current, total int, item ...string) {
|
func Progress(verb string, current, total int, item ...string) {
|
||||||
msg := compileGlyphs(i18n.Progress(verb))
|
msg := compileGlyphs(i18n.Progress(verb))
|
||||||
if len(item) > 0 && item[0] != "" {
|
if len(item) > 0 && item[0] != "" {
|
||||||
fmt.Fprintf(os.Stderr, "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0]))
|
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0]))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProgressDone clears the progress line.
|
// ProgressDone clears the progress line.
|
||||||
func ProgressDone() {
|
func ProgressDone() {
|
||||||
fmt.Fprint(os.Stderr, "\033[2K\r")
|
fmt.Fprint(stderrWriter(), "\033[2K\r")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label prints a "Label: value" line.
|
// Label prints a "Label: value" line.
|
||||||
func Label(word, value string) {
|
func Label(word, value string) {
|
||||||
fmt.Printf("%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
|
fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scanln reads from stdin.
|
// Scanln reads from stdin.
|
||||||
|
|
@ -140,7 +139,7 @@ func Scanln(a ...any) (int, error) {
|
||||||
// cli.Task("php", "Running tests...") // [php] Running tests...
|
// cli.Task("php", "Running tests...") // [php] Running tests...
|
||||||
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
||||||
func Task(label, message string) {
|
func Task(label, message string) {
|
||||||
fmt.Printf("%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
|
fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section prints a section header: "── SECTION ──"
|
// Section prints a section header: "── SECTION ──"
|
||||||
|
|
@ -149,7 +148,7 @@ func Task(label, message string) {
|
||||||
func Section(name string) {
|
func Section(name string) {
|
||||||
dash := Glyph(":dash:")
|
dash := Glyph(":dash:")
|
||||||
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
|
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
|
||||||
fmt.Println(AccentStyle.Render(header))
|
fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hint prints a labelled hint: "label: message"
|
// Hint prints a labelled hint: "label: message"
|
||||||
|
|
@ -157,7 +156,7 @@ func Section(name string) {
|
||||||
// cli.Hint("install", "composer require vimeo/psalm")
|
// cli.Hint("install", "composer require vimeo/psalm")
|
||||||
// cli.Hint("fix", "core php fmt --fix")
|
// cli.Hint("fix", "core php fmt --fix")
|
||||||
func Hint(label, message string) {
|
func Hint(label, message string) {
|
||||||
fmt.Printf(" %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
|
fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Severity prints a severity-styled message.
|
// Severity prints a severity-styled message.
|
||||||
|
|
@ -180,7 +179,7 @@ func Severity(level, message string) {
|
||||||
default:
|
default:
|
||||||
style = DimStyle
|
style = DimStyle
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
|
fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result prints a result line: "✓ message" or "✗ message"
|
// Result prints a result line: "✓ message" or "✗ message"
|
||||||
|
|
|
||||||
|
|
@ -159,3 +159,27 @@ func TestScanln_UsesOverrideStdin(t *testing.T) {
|
||||||
t.Fatalf("expected %q, got %q", "hello", got)
|
t.Fatalf("expected %q, got %q", "hello", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOutputSetters_Good(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
var err bytes.Buffer
|
||||||
|
|
||||||
|
SetStdout(&out)
|
||||||
|
SetStderr(&err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SetStdout(nil)
|
||||||
|
SetStderr(nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
Success("done")
|
||||||
|
Error("fail")
|
||||||
|
Info("note")
|
||||||
|
Warn("careful")
|
||||||
|
|
||||||
|
if out.Len() == 0 {
|
||||||
|
t.Fatal("expected stdout writer to receive output")
|
||||||
|
}
|
||||||
|
if err.Len() == 0 {
|
||||||
|
t.Fatal("expected stderr writer to receive output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,16 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var stdin io.Reader = os.Stdin
|
|
||||||
|
|
||||||
// SetStdin overrides the default stdin reader for testing.
|
|
||||||
// Pass nil to restore the real os.Stdin reader.
|
|
||||||
func SetStdin(r io.Reader) {
|
|
||||||
if r == nil {
|
|
||||||
stdin = os.Stdin
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stdin = r
|
|
||||||
}
|
|
||||||
|
|
||||||
// newReader wraps stdin in a bufio.Reader if it isn't one already.
|
// newReader wraps stdin in a bufio.Reader if it isn't one already.
|
||||||
func newReader() *bufio.Reader {
|
func newReader() *bufio.Reader {
|
||||||
if br, ok := stdin.(*bufio.Reader); ok {
|
if br, ok := stdinReader().(*bufio.Reader); ok {
|
||||||
return br
|
return br
|
||||||
}
|
}
|
||||||
return bufio.NewReader(stdin)
|
return bufio.NewReader(stdinReader())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt asks for text input with a default value.
|
// Prompt asks for text input with a default value.
|
||||||
|
|
@ -35,9 +22,9 @@ func Prompt(label, defaultVal string) (string, error) {
|
||||||
label = compileGlyphs(label)
|
label = compileGlyphs(label)
|
||||||
defaultVal = compileGlyphs(defaultVal)
|
defaultVal = compileGlyphs(defaultVal)
|
||||||
if defaultVal != "" {
|
if defaultVal != "" {
|
||||||
fmt.Fprintf(os.Stderr, "%s [%s]: ", label, defaultVal)
|
fmt.Fprintf(stderrWriter(), "%s [%s]: ", label, defaultVal)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "%s: ", label)
|
fmt.Fprintf(stderrWriter(), "%s: ", label)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := newReader()
|
r := newReader()
|
||||||
|
|
@ -66,11 +53,11 @@ func Select(label string, options []string) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr, compileGlyphs(label))
|
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
|
||||||
for i, opt := range options {
|
for i, opt := range options {
|
||||||
fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, compileGlyphs(opt))
|
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Choose [1-%d]: ", len(options))
|
fmt.Fprintf(stderrWriter(), "Choose [1-%d]: ", len(options))
|
||||||
|
|
||||||
r := newReader()
|
r := newReader()
|
||||||
input, err := r.ReadString('\n')
|
input, err := r.ReadString('\n')
|
||||||
|
|
@ -94,11 +81,11 @@ func MultiSelect(label string, options []string) ([]string, error) {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr, compileGlyphs(label))
|
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
|
||||||
for i, opt := range options {
|
for i, opt := range options {
|
||||||
fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, compileGlyphs(opt))
|
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Choose (space-separated) [1-%d]: ", len(options))
|
fmt.Fprintf(stderrWriter(), "Choose (space-separated) [1-%d]: ", len(options))
|
||||||
|
|
||||||
r := newReader()
|
r := newReader()
|
||||||
input, err := r.ReadString('\n')
|
input, err := r.ReadString('\n')
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,20 @@ func TestSetStdin_Good_ResetNil(t *testing.T) {
|
||||||
assert.Same(t, os.Stdin, stdin)
|
assert.Same(t, os.Stdin, stdin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrompt_Good_UsesStderrSetter(t *testing.T) {
|
||||||
|
SetStdin(strings.NewReader("alice\n"))
|
||||||
|
defer SetStdin(nil)
|
||||||
|
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
SetStderr(&errBuf)
|
||||||
|
defer SetStderr(nil)
|
||||||
|
|
||||||
|
val, err := Prompt("Name", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "alice", val)
|
||||||
|
assert.Contains(t, errBuf.String(), "Name")
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptHints_Good_UseStderr(t *testing.T) {
|
func TestPromptHints_Good_UseStderr(t *testing.T) {
|
||||||
oldOut := os.Stdout
|
oldOut := os.Stdout
|
||||||
oldErr := os.Stderr
|
oldErr := os.Stderr
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||||
|
|
||||||
// Render outputs the layout to terminal.
|
// Render outputs the layout to terminal.
|
||||||
func (c *Composite) Render() {
|
func (c *Composite) Render() {
|
||||||
fmt.Print(c.String())
|
fmt.Fprint(stdoutWriter(), c.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// String returns the rendered layout.
|
// String returns the rendered layout.
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ func (t *Table) String() string {
|
||||||
|
|
||||||
// Render prints the table to stdout.
|
// Render prints the table to stdout.
|
||||||
func (t *Table) Render() {
|
func (t *Table) Render() {
|
||||||
fmt.Print(t.String())
|
fmt.Fprint(stdoutWriter(), t.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Table) colCount() int {
|
func (t *Table) colCount() int {
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
|
||||||
|
|
||||||
// NewTaskTracker creates a new parallel task tracker.
|
// NewTaskTracker creates a new parallel task tracker.
|
||||||
func NewTaskTracker() *TaskTracker {
|
func NewTaskTracker() *TaskTracker {
|
||||||
return &TaskTracker{out: os.Stderr}
|
return &TaskTracker{out: stderrWriter()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOutput sets the destination writer for tracker output.
|
// WithOutput sets the destination writer for tracker output.
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func (n *TreeNode) String() string {
|
||||||
|
|
||||||
// Render prints the tree to stdout.
|
// Render prints the tree to stdout.
|
||||||
func (n *TreeNode) Render() {
|
func (n *TreeNode) Render() {
|
||||||
fmt.Print(n.String())
|
fmt.Fprint(stdoutWriter(), n.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *TreeNode) renderLabel() string {
|
func (n *TreeNode) renderLabel() string {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -44,11 +43,11 @@ type confirmConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func promptHint(msg string) {
|
func promptHint(msg string) {
|
||||||
fmt.Fprintln(os.Stderr, DimStyle.Render(compileGlyphs(msg)))
|
fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func promptWarning(msg string) {
|
func promptWarning(msg string) {
|
||||||
fmt.Fprintln(os.Stderr, WarningStyle.Render(compileGlyphs(msg)))
|
fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||||
|
|
@ -114,7 +113,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||||
reader := newReader()
|
reader := newReader()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
fmt.Fprintf(os.Stderr, "%s %s", prompt, suffix)
|
fmt.Fprintf(stderrWriter(), "%s %s", prompt, suffix)
|
||||||
|
|
||||||
var response string
|
var response string
|
||||||
var readErr error
|
var readErr error
|
||||||
|
|
@ -134,7 +133,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||||
readErr = <-errChan
|
readErr = <-errChan
|
||||||
response = strings.ToLower(strings.TrimSpace(response))
|
response = strings.ToLower(strings.TrimSpace(response))
|
||||||
case <-time.After(cfg.timeout):
|
case <-time.After(cfg.timeout):
|
||||||
fmt.Fprintln(os.Stderr) // New line after timeout
|
fmt.Fprintln(stderrWriter()) // New line after timeout
|
||||||
return cfg.defaultYes
|
return cfg.defaultYes
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -251,9 +250,9 @@ func Question(prompt string, opts ...QuestionOption) string {
|
||||||
for {
|
for {
|
||||||
// Build prompt with default
|
// Build prompt with default
|
||||||
if cfg.defaultValue != "" {
|
if cfg.defaultValue != "" {
|
||||||
fmt.Fprintf(os.Stderr, "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
|
fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "%s ", prompt)
|
fmt.Fprintf(stderrWriter(), "%s ", prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
response, err := reader.ReadString('\n')
|
response, err := reader.ReadString('\n')
|
||||||
|
|
@ -375,9 +374,9 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
||||||
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
|
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
|
||||||
|
|
||||||
if cfg.filter {
|
if cfg.filter {
|
||||||
fmt.Fprintf(os.Stderr, "Enter number [1-%d] or filter: ", len(visible))
|
fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(os.Stderr, "Enter number [1-%d]: ", len(visible))
|
fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible))
|
||||||
}
|
}
|
||||||
response, err := reader.ReadString('\n')
|
response, err := reader.ReadString('\n')
|
||||||
response = strings.TrimSpace(response)
|
response = strings.TrimSpace(response)
|
||||||
|
|
@ -474,9 +473,9 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||||
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
|
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
|
||||||
|
|
||||||
if cfg.filter {
|
if cfg.filter {
|
||||||
fmt.Fprint(os.Stderr, "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
|
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprint(os.Stderr, "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||||
}
|
}
|
||||||
response, _ := reader.ReadString('\n')
|
response, _ := reader.ReadString('\n')
|
||||||
response = strings.TrimSpace(response)
|
response = strings.TrimSpace(response)
|
||||||
|
|
@ -512,16 +511,16 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
|
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
|
||||||
fmt.Fprintln(os.Stderr, prompt)
|
fmt.Fprintln(stderrWriter(), prompt)
|
||||||
for i, idx := range visible {
|
for i, idx := range visible {
|
||||||
marker := " "
|
marker := " "
|
||||||
if defaultN >= 0 && idx == defaultN {
|
if defaultN >= 0 && idx == defaultN {
|
||||||
marker = "*"
|
marker = "*"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
|
fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
|
||||||
}
|
}
|
||||||
if filter {
|
if filter {
|
||||||
fmt.Fprintln(os.Stderr, " (type to filter the list)")
|
fmt.Fprintln(stderrWriter(), " (type to filter the list)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue