Skip to content

Commit 4837a4f

Browse files
committed
feat(cli): add IOStreams for centralized I/O and testability
Introduce IOStreams as the single owner of stdin/stdout/stderr with per-stream TTY detection, color awareness, pager integration, and prompt safety (CanPrompt). - System() for production, Test() for deterministic buffer-backed tests - cli.IO(cmd) extracts IOStreams from context; Output(cmd) and Prompter(cmd) remain as convenience shortcuts (unchanged API) - printer.NewOutputFrom(w, errW, tty) for explicit stream construction - SetStdoutTTY, SetColorEnabled, SetNeverPrompt for test overrides - StartPager/StopPager on IOStreams replaces direct terminal.Pager use
1 parent 2eab3de commit 4837a4f

7 files changed

Lines changed: 427 additions & 23 deletions

File tree

MIGRATION.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ rootCmd.AddGroup(&cobra.Group{ID: "manage", Title: "Management:"})
150150
cmd.GroupID = "manage"
151151
```
152152

153-
Access shared output and prompting in commands:
153+
Access shared output, prompting, and I/O capabilities in commands:
154154

155155
```go
156156
func newListCmd() *cobra.Command {
@@ -165,6 +165,48 @@ func newListCmd() *cobra.Command {
165165
}
166166
```
167167

168+
`cli.IO(cmd)` provides the full IOStreams for commands that need richer control:
169+
170+
```go
171+
func newDeleteCmd() *cobra.Command {
172+
return &cobra.Command{
173+
Use: "delete",
174+
RunE: func(cmd *cobra.Command, args []string) error {
175+
ios := cli.IO(cmd)
176+
if !ios.CanPrompt() {
177+
return fmt.Errorf("--yes required in non-interactive mode")
178+
}
179+
ok, _ := ios.Prompter().Confirm("Delete?", false)
180+
if !ok {
181+
return cli.ErrCancel
182+
}
183+
// Use pager for long output
184+
ios.StartPager()
185+
defer ios.StopPager()
186+
ios.Output().Markdown(longDoc)
187+
return nil
188+
},
189+
}
190+
}
191+
```
192+
193+
For testing commands, `cli.Test()` returns IOStreams backed by buffers:
194+
195+
```go
196+
func TestListCmd(t *testing.T) {
197+
ios, _, stdout, _ := cli.Test()
198+
ios.SetStdoutTTY(true) // simulate a terminal
199+
200+
cmd := newListCmd()
201+
ctx := context.WithValue(context.Background(), cli.ContextKey(), ios)
202+
cmd.SetContext(ctx)
203+
cmd.SetArgs([]string{})
204+
require.NoError(t, cmd.Execute())
205+
206+
assert.Contains(t, stdout.String(), "Alice")
207+
}
208+
```
209+
168210
## Error handling
169211

170212
`commander.IsCommandErr` (string matching) and manual error handling are replaced by `cli.Execute`:

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import (
5050

5151
func main() {
5252
rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"}
53-
rootCmd.PersistentFlags().StringP("host", "h", "", "API server host")
53+
rootCmd.PersistentFlags().String("host", "", "API server host")
5454
rootCmd.AddCommand(serverCmd, userCmd)
5555

5656
cli.Init(rootCmd,
@@ -61,7 +61,7 @@ func main() {
6161
}
6262
```
6363

64-
`Init` adds help, shell completion, reference docs, and silences Cobra's default error output. `Execute` runs the command and handles all errors with proper exit codes. Commands access shared output via `cli.Output(cmd)`.
64+
`Init` adds help, shell completion, reference docs, and silences Cobra's default error output. `Execute` runs the command and handles all errors with proper exit codes. Commands access shared I/O via `cli.IO(cmd)`, or the convenience helpers `cli.Output(cmd)` and `cli.Prompter(cmd)`. Use `cli.Test()` in tests for captured, deterministic output.
6565

6666
## Installation
6767

cli/cli.go

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111
// )
1212
//
1313
// cli.Execute(rootCmd)
14+
//
15+
// Commands access shared I/O via [IO], or the convenience helpers
16+
// [Output] and [Prompter]:
17+
//
18+
// ios := cli.IO(cmd) // full IOStreams
19+
// out := cli.Output(cmd) // formatting (table, JSON, spinner)
20+
// p := cli.Prompter(cmd) // interactive prompts
21+
//
22+
// For testing, [Test] returns IOStreams backed by buffers:
23+
//
24+
// ios, stdin, stdout, stderr := cli.Test()
25+
// ios.SetStdoutTTY(true)
1426
package cli
1527

1628
import (
@@ -28,10 +40,10 @@ import (
2840

2941
type contextKey struct{}
3042

31-
type cliContext struct {
32-
output *printer.Output
33-
prompter prompt.Prompter
34-
}
43+
// ContextKey returns the context key used to store IOStreams.
44+
// This is primarily useful for tests that need to inject IOStreams
45+
// into a command's context directly.
46+
func ContextKey() contextKey { return contextKey{} }
3547

3648
// Init enhances a cobra root command with standard CLI features:
3749
// help, completion, reference docs, output/prompter context, and
@@ -52,16 +64,13 @@ func Init(rootCmd *cobra.Command, opts ...Option) {
5264
rootCmd.SilenceErrors = true
5365
rootCmd.SilenceUsage = true
5466

55-
// Inject shared output and prompter into command context.
67+
// Inject IOStreams into command context.
5668
// Preserve any existing PersistentPreRun or PersistentPreRunE hook.
5769
existingRun := rootCmd.PersistentPreRun
5870
existingRunE := rootCmd.PersistentPreRunE
5971
rootCmd.PersistentPreRun = nil
6072
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
61-
ctx := context.WithValue(cmd.Context(), contextKey{}, &cliContext{
62-
output: printer.NewOutput(os.Stdout),
63-
prompter: prompt.New(),
64-
})
73+
ctx := context.WithValue(cmd.Context(), contextKey{}, System())
6574
cmd.SetContext(ctx)
6675
if existingRunE != nil {
6776
return existingRunE(cmd, args)
@@ -139,22 +148,23 @@ func versionCmd(name, ver, repo string) *cobra.Command {
139148
}
140149
}
141150

142-
// Output extracts the shared printer from a command's context.
143-
func Output(cmd *cobra.Command) *printer.Output {
151+
// IO extracts the IOStreams from a command's context.
152+
// Returns a default System() IOStreams if none was injected.
153+
func IO(cmd *cobra.Command) *IOStreams {
144154
if ctx := cmd.Context(); ctx != nil {
145-
if cc, ok := ctx.Value(contextKey{}).(*cliContext); ok {
146-
return cc.output
155+
if ios, ok := ctx.Value(contextKey{}).(*IOStreams); ok {
156+
return ios
147157
}
148158
}
149-
return printer.NewOutput(os.Stdout)
159+
return System()
160+
}
161+
162+
// Output extracts the shared printer from a command's context.
163+
func Output(cmd *cobra.Command) *printer.Output {
164+
return IO(cmd).Output()
150165
}
151166

152167
// Prompter extracts the shared prompter from a command's context.
153168
func Prompter(cmd *cobra.Command) prompt.Prompter {
154-
if ctx := cmd.Context(); ctx != nil {
155-
if cc, ok := ctx.Value(contextKey{}).(*cliContext); ok {
156-
return cc.prompter
157-
}
158-
}
159-
return prompt.New()
169+
return IO(cmd).Prompter()
160170
}

cli/example_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli_test
22

33
import (
4+
"fmt"
5+
46
"github.com/raystack/salt/cli"
57
"github.com/raystack/salt/cli/commander"
68
"github.com/spf13/cobra"
@@ -51,6 +53,45 @@ func ExampleExecute() {
5153
cli.Execute(rootCmd)
5254
}
5355

56+
func ExampleIO() {
57+
deleteCmd := &cobra.Command{
58+
Use: "delete",
59+
RunE: func(cmd *cobra.Command, args []string) error {
60+
ios := cli.IO(cmd)
61+
62+
// Guard interactive prompts in non-TTY environments.
63+
if !ios.CanPrompt() {
64+
return fmt.Errorf("--yes flag required in non-interactive mode")
65+
}
66+
67+
ok, _ := ios.Prompter().Confirm("Delete resource?", false)
68+
if !ok {
69+
return cli.ErrCancel
70+
}
71+
72+
ios.Output().Success("deleted")
73+
return nil
74+
},
75+
}
76+
77+
rootCmd := &cobra.Command{Use: "myapp"}
78+
rootCmd.AddCommand(deleteCmd)
79+
cli.Init(rootCmd)
80+
cli.Execute(rootCmd)
81+
}
82+
83+
func ExampleTest() {
84+
// Use cli.Test() in unit tests to capture output.
85+
ios, _, stdout, _ := cli.Test()
86+
ios.SetStdoutTTY(true) // simulate a terminal
87+
88+
out := ios.Output()
89+
out.Println("hello from test")
90+
91+
fmt.Print(stdout.String())
92+
// Output: hello from test
93+
}
94+
5495
func ExampleInit_withTopics() {
5596
rootCmd := &cobra.Command{
5697
Use: "myapp",

cli/iostreams.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
8+
"github.com/mattn/go-isatty"
9+
"github.com/muesli/termenv"
10+
"github.com/raystack/salt/cli/printer"
11+
"github.com/raystack/salt/cli/prompt"
12+
"github.com/raystack/salt/cli/terminal"
13+
"golang.org/x/term"
14+
)
15+
16+
// IOStreams provides centralized access to standard I/O streams and
17+
// terminal capabilities for CLI commands.
18+
//
19+
// Use [System] for production and [Test] for tests. Commands access
20+
// it via [IO]:
21+
//
22+
// ios := cli.IO(cmd)
23+
// if !ios.CanPrompt() {
24+
// return fmt.Errorf("--yes required in non-interactive mode")
25+
// }
26+
type IOStreams struct {
27+
In io.ReadCloser // standard input
28+
Out io.Writer // standard output (may become pager pipe)
29+
ErrOut io.Writer // standard error
30+
31+
inTTY bool
32+
outTTY bool
33+
errTTY bool
34+
35+
colorEnabled bool
36+
neverPrompt bool
37+
38+
pager *terminal.Pager
39+
pagerStarted bool
40+
41+
// lazily created
42+
output *printer.Output
43+
prompter prompt.Prompter
44+
}
45+
46+
// System creates IOStreams wired to the real terminal.
47+
func System() *IOStreams {
48+
outTTY := isTTYWriter(os.Stdout)
49+
return &IOStreams{
50+
In: os.Stdin,
51+
Out: os.Stdout,
52+
ErrOut: os.Stderr,
53+
inTTY: isTTYWriter(os.Stdin),
54+
outTTY: outTTY,
55+
errTTY: isTTYWriter(os.Stderr),
56+
colorEnabled: outTTY && !termenv.EnvNoColor(),
57+
}
58+
}
59+
60+
// Test creates IOStreams backed by buffers for deterministic testing.
61+
// All TTY flags default to false and color is disabled.
62+
func Test() (ios *IOStreams, stdin *bytes.Buffer, stdout *bytes.Buffer, stderr *bytes.Buffer) {
63+
stdin = &bytes.Buffer{}
64+
stdout = &bytes.Buffer{}
65+
stderr = &bytes.Buffer{}
66+
ios = &IOStreams{
67+
In: io.NopCloser(stdin),
68+
Out: stdout,
69+
ErrOut: stderr,
70+
}
71+
return
72+
}
73+
74+
// IsStdinTTY reports whether standard input is a terminal.
75+
func (s *IOStreams) IsStdinTTY() bool { return s.inTTY }
76+
77+
// IsStdoutTTY reports whether standard output is a terminal.
78+
func (s *IOStreams) IsStdoutTTY() bool { return s.outTTY }
79+
80+
// IsStderrTTY reports whether standard error is a terminal.
81+
func (s *IOStreams) IsStderrTTY() bool { return s.errTTY }
82+
83+
// SetStdinTTY overrides the stdin TTY flag (useful in tests).
84+
func (s *IOStreams) SetStdinTTY(v bool) { s.inTTY = v }
85+
86+
// SetStdoutTTY overrides the stdout TTY flag (useful in tests).
87+
func (s *IOStreams) SetStdoutTTY(v bool) { s.outTTY = v; s.output = nil }
88+
89+
// SetStderrTTY overrides the stderr TTY flag (useful in tests).
90+
func (s *IOStreams) SetStderrTTY(v bool) { s.errTTY = v }
91+
92+
// SetColorEnabled overrides color detection (useful in tests).
93+
func (s *IOStreams) SetColorEnabled(v bool) { s.colorEnabled = v }
94+
95+
// SetNeverPrompt disables interactive prompting regardless of TTY state.
96+
func (s *IOStreams) SetNeverPrompt(v bool) { s.neverPrompt = v }
97+
98+
// ColorEnabled reports whether color output is active.
99+
func (s *IOStreams) ColorEnabled() bool { return s.colorEnabled }
100+
101+
// CanPrompt reports whether interactive prompting is possible.
102+
// Returns false if prompting is disabled, or stdin/stdout are not terminals.
103+
func (s *IOStreams) CanPrompt() bool {
104+
return !s.neverPrompt && s.inTTY && s.outTTY
105+
}
106+
107+
// TerminalWidth returns the terminal width in columns.
108+
// Returns 80 if the width cannot be determined.
109+
func (s *IOStreams) TerminalWidth() int {
110+
if f, ok := s.Out.(*os.File); ok {
111+
if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
112+
return w
113+
}
114+
}
115+
return 80
116+
}
117+
118+
// StartPager starts a pager process and redirects Out through it.
119+
// Does nothing if stdout is not a TTY or no pager command is configured.
120+
func (s *IOStreams) StartPager() error {
121+
if !s.outTTY {
122+
return nil
123+
}
124+
p := terminal.NewPager()
125+
p.Out = s.Out
126+
p.ErrOut = s.ErrOut
127+
if err := p.Start(); err != nil {
128+
return err
129+
}
130+
s.Out = p.Out
131+
s.pager = p
132+
s.pagerStarted = true
133+
s.output = nil // invalidate cached Output
134+
return nil
135+
}
136+
137+
// StopPager stops the pager process and restores the original Out.
138+
func (s *IOStreams) StopPager() {
139+
if s.pager != nil && s.pagerStarted {
140+
s.pager.Stop()
141+
s.pagerStarted = false
142+
}
143+
}
144+
145+
// Output returns the formatting layer, creating it lazily.
146+
func (s *IOStreams) Output() *printer.Output {
147+
if s.output == nil {
148+
s.output = printer.NewOutputFrom(s.Out, s.ErrOut, s.outTTY)
149+
}
150+
return s.output
151+
}
152+
153+
// Prompter returns the prompt layer, creating it lazily.
154+
func (s *IOStreams) Prompter() prompt.Prompter {
155+
if s.prompter == nil {
156+
s.prompter = prompt.New()
157+
}
158+
return s.prompter
159+
}
160+
161+
func isTTYWriter(f *os.File) bool {
162+
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
163+
}

0 commit comments

Comments
 (0)