Skip to content

Commit 981a38f

Browse files
authored
[PRDGRO-1991] Default to JSON Output in Non-TUI Contexts (#261)
* Add an ourput resolver to try and be smart about when we use interactive mode or an ourput like JSON or YAML * Add tests with expected behaviour for context.go * Add runtimesignal to deps * Configure output format based on ResolveAutoOutput * tidy up unused code * Add root.go tests * Update json to non-tty default * Update tests for JSON default * Need to clear the envvars in our tests otherwise values in CI affect them * Replace RENDER_INTERACTIVE override with RENDER_OUTPUT override to support all output modes. -o take precidense * Return an error if there's an invalid RENDER_OUTPUT value instead of setting it on the RuntimeSignals object * Grammaer on help message
1 parent c0def2d commit 981a38f

7 files changed

Lines changed: 728 additions & 29 deletions

File tree

cmd/root.go

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ start psql/SSH sessions, and more.
3434
3535
The CLI's default %s mode provides intuitive, menu-based navigation.
3636
37-
To use in %s mode (such as in a script), set each command's --output
38-
option to either json or yaml for structured responses.
37+
To use in %s mode (such as in a script), you can set each command's --output
38+
option to either json or yaml for structured responses. We'll also detect if stdout
39+
is not a TTY and automatically switch to json output.
3940
`, welcomeMsg, renderstyle.Bold("interactive"), renderstyle.Bold("non-interactive"))
4041

4142
// rootCmd represents the base command when called without any subcommands
@@ -80,21 +81,6 @@ var rootCmd = &cobra.Command{
8081
},
8182
}
8283

83-
func isPipe() bool {
84-
stdout, err := os.Stdout.Stat()
85-
if err != nil {
86-
return false
87-
}
88-
89-
isTerminal := (stdout.Mode() & os.ModeCharDevice) == os.ModeCharDevice
90-
return !isTerminal
91-
}
92-
93-
func isCI() bool {
94-
ci := os.Getenv("CI")
95-
return ci == "true" || ci == "1"
96-
}
97-
9884
func setupWorkflowCommands(deps *dependencies.Dependencies) {
9985
deps.Commands.Workflow.TaskListCmd = NewTaskListCmd(deps)
10086
deps.Commands.Workflow.TaskRunCmd = NewTaskRunStartCmd(deps)
@@ -148,7 +134,11 @@ func SetupCommands() error {
148134
}
149135

150136
func setupRootCmdPersistentRun(deps *dependencies.Dependencies) {
151-
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
137+
rootCmd.PersistentPreRunE = rootPersistentPreRun(deps)
138+
}
139+
140+
func rootPersistentPreRun(deps *dependencies.Dependencies) func(cmd *cobra.Command, args []string) error {
141+
return func(cmd *cobra.Command, args []string) error {
152142
ctx := cmd.Context()
153143

154144
if err := checkForDeprecatedFlagUsage(cmd); err != nil {
@@ -167,15 +157,24 @@ func setupRootCmdPersistentRun(deps *dependencies.Dependencies) {
167157
panic(err)
168158
}
169159

170-
output, err := command.StringToOutput(outputFlag)
160+
requestedOutput, err := command.StringToOutput(outputFlag)
171161
if err != nil {
172162
println(err.Error())
173163
os.Exit(1)
174164
}
175-
// Honor the output flag if it's set
176-
if outputFlag == "" && output.Interactive() && (isPipe() || isCI()) {
177-
output = command.TEXT
165+
166+
explicitOutputSet := cmd.Flags().Changed("output")
167+
signals, err := deps.DetectRuntimeSignals()
168+
if err != nil {
169+
println(err.Error())
170+
os.Exit(1)
178171
}
172+
output, err := command.ResolveAutoOutput(explicitOutputSet, requestedOutput, signals)
173+
if err != nil {
174+
println(err.Error())
175+
os.Exit(1)
176+
}
177+
179178
ctx = command.SetFormatInContext(ctx, &output)
180179

181180
deps.SetStack(tui.NewStack())
@@ -240,7 +239,7 @@ func init() {
240239

241240
rootCmd.Version = cfg.Version
242241
rootCmd.CompletionOptions.DisableDefaultCmd = true
243-
rootCmd.PersistentFlags().StringP("output", "o", "interactive", "interactive, json, yaml, or text")
242+
rootCmd.PersistentFlags().StringP("output", "o", "interactive", "interactive, json, yaml, or text (auto: json in non-TTY contexts)")
244243
rootCmd.PersistentFlags().Bool(command.ConfirmFlag, false, "set to skip confirmation prompts")
245244

246245
// Flags from the old CLI that we error with a helpful message

cmd/root_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// pattern: Imperative Shell
2+
package cmd
3+
4+
import (
5+
"context"
6+
"testing"
7+
8+
"github.com/render-oss/cli/pkg/command"
9+
"github.com/render-oss/cli/pkg/dependencies"
10+
"github.com/render-oss/cli/pkg/tui"
11+
"github.com/spf13/cobra"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestRootPersistentPreRunOutputResolution(t *testing.T) {
16+
testCases := []struct {
17+
name string
18+
input runRootPersistentPreRunInput
19+
wantOutput command.Output
20+
wantStackContext bool
21+
}{
22+
{
23+
name: "default output with unchanged flag uses auto mode and resolves json for non-tty",
24+
input: runRootPersistentPreRunInput{
25+
explicitOutput: false,
26+
outputValue: "interactive",
27+
signals: command.RuntimeSignals{
28+
StdinTTY: true,
29+
StdoutTTY: false,
30+
StderrTTY: true,
31+
},
32+
},
33+
wantOutput: command.JSON,
34+
},
35+
{
36+
name: "explicit interactive remains interactive regardless of non-tty ci signals",
37+
input: runRootPersistentPreRunInput{
38+
explicitOutput: true,
39+
outputValue: "interactive",
40+
signals: command.RuntimeSignals{
41+
StdinTTY: false,
42+
StdoutTTY: false,
43+
StderrTTY: false,
44+
CI: true,
45+
},
46+
},
47+
wantOutput: command.Interactive,
48+
wantStackContext: true,
49+
},
50+
{
51+
name: "explicit json output is preserved",
52+
input: runRootPersistentPreRunInput{
53+
explicitOutput: true,
54+
outputValue: "json",
55+
signals: command.RuntimeSignals{
56+
StdinTTY: true,
57+
StdoutTTY: true,
58+
StderrTTY: true,
59+
},
60+
},
61+
wantOutput: command.JSON,
62+
},
63+
{
64+
name: "explicit output takes precedence over RENDER_OUTPUT",
65+
input: runRootPersistentPreRunInput{
66+
explicitOutput: true,
67+
outputValue: "interactive",
68+
signals: command.RuntimeSignals{
69+
ForcedOutput: outputPointer(command.JSON),
70+
StdinTTY: true,
71+
StdoutTTY: true,
72+
StderrTTY: true,
73+
},
74+
},
75+
wantOutput: command.Interactive,
76+
wantStackContext: true,
77+
},
78+
{
79+
name: "explicit structured output takes precedence over RENDER_OUTPUT",
80+
input: runRootPersistentPreRunInput{
81+
explicitOutput: true,
82+
outputValue: "yaml",
83+
signals: command.RuntimeSignals{
84+
ForcedOutput: outputPointer(command.Interactive),
85+
StdinTTY: false,
86+
StdoutTTY: false,
87+
StderrTTY: false,
88+
CI: true,
89+
},
90+
},
91+
wantOutput: command.YAML,
92+
},
93+
{
94+
name: "explicit yaml output is preserved",
95+
input: runRootPersistentPreRunInput{
96+
explicitOutput: true,
97+
outputValue: "yaml",
98+
signals: command.RuntimeSignals{
99+
StdinTTY: true,
100+
StdoutTTY: true,
101+
StderrTTY: true,
102+
},
103+
},
104+
wantOutput: command.YAML,
105+
},
106+
{
107+
name: "ci truthy in auto mode resolves json",
108+
input: runRootPersistentPreRunInput{
109+
explicitOutput: false,
110+
outputValue: "interactive",
111+
signals: command.RuntimeSignals{
112+
StdinTTY: true,
113+
StdoutTTY: true,
114+
StderrTTY: true,
115+
CI: true,
116+
},
117+
},
118+
wantOutput: command.JSON,
119+
},
120+
{
121+
name: "all tty and ci false in auto mode resolves interactive",
122+
input: runRootPersistentPreRunInput{
123+
explicitOutput: false,
124+
outputValue: "interactive",
125+
signals: command.RuntimeSignals{
126+
StdinTTY: true,
127+
StdoutTTY: true,
128+
StderrTTY: true,
129+
CI: false,
130+
},
131+
},
132+
wantOutput: command.Interactive,
133+
wantStackContext: true,
134+
},
135+
}
136+
137+
for _, tc := range testCases {
138+
t.Run(tc.name, func(t *testing.T) {
139+
result := runRootPersistentPreRun(t, tc.input)
140+
141+
output := command.GetFormatFromContext(result.cmd.Context())
142+
require.NotNil(t, output)
143+
require.Equal(t, tc.wantOutput, *output)
144+
145+
stack := tui.GetStackFromContext(result.cmd.Context())
146+
if tc.wantStackContext {
147+
require.NotNil(t, stack)
148+
require.Equal(t, result.deps.Stack(), stack)
149+
return
150+
}
151+
152+
require.Nil(t, stack)
153+
})
154+
}
155+
}
156+
157+
type runRootPersistentPreRunInput struct {
158+
explicitOutput bool
159+
outputValue string
160+
signals command.RuntimeSignals
161+
}
162+
163+
type runRootPersistentPreRunResult struct {
164+
cmd *cobra.Command
165+
deps *dependencies.Dependencies
166+
}
167+
168+
func runRootPersistentPreRun(t *testing.T, input runRootPersistentPreRunInput) runRootPersistentPreRunResult {
169+
t.Helper()
170+
171+
deps := dependencies.New(nil)
172+
deps.DetectRuntimeSignals = func() (command.RuntimeSignals, error) {
173+
return input.signals, nil
174+
}
175+
preRun := rootPersistentPreRun(deps)
176+
177+
cmd := &cobra.Command{Use: "render"}
178+
cmd.Flags().StringP("output", "o", "interactive", "interactive, json, yaml, or text")
179+
cmd.Flags().Bool(command.ConfirmFlag, false, "set to skip confirmation prompts")
180+
cmd.SetContext(context.Background())
181+
182+
if input.explicitOutput {
183+
require.NoError(t, cmd.Flags().Set("output", input.outputValue))
184+
}
185+
186+
require.NoError(t, preRun(cmd, []string{}))
187+
return runRootPersistentPreRunResult{
188+
cmd: cmd,
189+
deps: deps,
190+
}
191+
}
192+
193+
func outputPointer(output command.Output) *command.Output {
194+
return &output
195+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ require (
1313
github.com/charmbracelet/x/exp/teatest v0.0.0-20240919170804-a4978c8e603a
1414
github.com/evertras/bubble-table v0.17.0
1515
github.com/go-chi/chi/v5 v5.2.2
16+
github.com/google/uuid v1.6.0
1617
github.com/gorilla/websocket v1.5.3
1718
github.com/jedib0t/go-pretty v4.3.0+incompatible
19+
github.com/mattn/go-isatty v0.0.20
1820
github.com/oapi-codegen/runtime v1.1.1
1921
github.com/spf13/cobra v1.8.1
2022
github.com/spf13/pflag v1.0.5
@@ -38,10 +40,8 @@ require (
3840
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3941
github.com/go-openapi/errors v0.22.0 // indirect
4042
github.com/go-openapi/strfmt v0.23.0 // indirect
41-
github.com/google/uuid v1.6.0 // indirect
4243
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4344
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
44-
github.com/mattn/go-isatty v0.0.20 // indirect
4545
github.com/mattn/go-localereader v0.0.1 // indirect
4646
github.com/mattn/go-runewidth v0.0.16 // indirect
4747
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect

pkg/command/context_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// pattern: Imperative Shell
2+
package command_test
3+
4+
import (
5+
"context"
6+
"testing"
7+
8+
"github.com/render-oss/cli/pkg/command"
9+
"github.com/spf13/cobra"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestDefaultFormatNonInteractive(t *testing.T) {
14+
testCases := []struct {
15+
name string
16+
setup func() context.Context
17+
wantOutput command.Output
18+
}{
19+
{
20+
name: "interactive output becomes text",
21+
setup: func() context.Context {
22+
output := command.Interactive
23+
return command.SetFormatInContext(context.Background(), &output)
24+
},
25+
wantOutput: command.TEXT,
26+
},
27+
{
28+
name: "json output remains json",
29+
setup: func() context.Context {
30+
output := command.JSON
31+
return command.SetFormatInContext(context.Background(), &output)
32+
},
33+
wantOutput: command.JSON,
34+
},
35+
{
36+
name: "yaml output remains yaml",
37+
setup: func() context.Context {
38+
output := command.YAML
39+
return command.SetFormatInContext(context.Background(), &output)
40+
},
41+
wantOutput: command.YAML,
42+
},
43+
{
44+
name: "text output remains text",
45+
setup: func() context.Context {
46+
output := command.TEXT
47+
return command.SetFormatInContext(context.Background(), &output)
48+
},
49+
wantOutput: command.TEXT,
50+
},
51+
{
52+
name: "nil output context becomes text",
53+
setup: func() context.Context {
54+
return context.Background()
55+
},
56+
wantOutput: command.TEXT,
57+
},
58+
}
59+
60+
for _, tc := range testCases {
61+
t.Run(tc.name, func(t *testing.T) {
62+
cmd := &cobra.Command{Use: "compatibility"}
63+
cmd.SetContext(tc.setup())
64+
65+
command.DefaultFormatNonInteractive(cmd)
66+
67+
output := command.GetFormatFromContext(cmd.Context())
68+
require.NotNil(t, output)
69+
require.Equal(t, tc.wantOutput, *output)
70+
})
71+
}
72+
}
73+
74+
func TestDefaultFormatNonInteractive_CommandFlowCompatibility(t *testing.T) {
75+
cmd := &cobra.Command{Use: "synthetic-command-flow"}
76+
output := command.Interactive
77+
cmd.SetContext(command.SetFormatInContext(context.Background(), &output))
78+
79+
command.DefaultFormatNonInteractive(cmd)
80+
81+
resolved := command.GetFormatFromContext(cmd.Context())
82+
require.NotNil(t, resolved)
83+
require.Equal(t, command.TEXT, *resolved)
84+
}

0 commit comments

Comments
 (0)