Skip to content

Commit ae23ad3

Browse files
committed
(feat) adding TTY detection for auto-JSON output
1 parent 03a2936 commit ae23ad3

8 files changed

Lines changed: 262 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Supported settings:
6767
* `base-uri` LaunchDarkly base URI (default "https://app.launchdarkly.com")
6868
- `environment`: Default environment key
6969
- `flag`: Default feature flag key
70-
- `output`: Command response output format in either JSON or plain text
70+
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
7171
- `project`: Default project key
7272

7373
Available `config` commands:

cmd/cliflags/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const (
4444
EnvironmentFlagDescription = "Default environment key"
4545
FlagFlagDescription = "Default feature flag key"
4646
JSONFlagDescription = "Output JSON format (shorthand for --output json)"
47-
OutputFlagDescription = "Command response output format in either JSON or plain text"
47+
OutputFlagDescription = "Output format: json or plaintext (default: plaintext in a terminal, json otherwise)"
4848
PortFlagDescription = "Port for the dev server to run on"
4949
ProjectFlagDescription = "Default project key"
5050
SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied."

cmd/cmdtest.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func CallCmd(
3131
clients,
3232
"test",
3333
false,
34+
func() bool { return true },
3435
)
3536
cmd := rootCmd.Cmd()
3637
require.NoError(t, err)

cmd/config/testdata/help.golden

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Supported settings:
99
- `dev-stream-uri`: Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint
1010
- `environment`: Default environment key
1111
- `flag`: Default feature flag key
12-
- `output`: Command response output format in either JSON or plain text
12+
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
1313
- `port`: Port for the dev server to run on
1414
- `project`: Default project key
1515
- `sync-once`: Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied.
@@ -28,4 +28,4 @@ Global Flags:
2828
--analytics-opt-out Opt out of analytics tracking
2929
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
3030
--json Output JSON format (shorthand for --output json)
31-
-o, --output string Command response output format in either JSON or plain text (default "plaintext")
31+
-o, --output string Output format: json or plaintext (default: plaintext in a terminal, json otherwise) (default "plaintext")

cmd/root.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/google/uuid"
1414
"github.com/spf13/cobra"
1515
"github.com/spf13/viper"
16+
"golang.org/x/term"
1617

1718
cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics"
1819
"github.com/launchdarkly/ldcli/cmd/cliflags"
@@ -87,6 +88,7 @@ func NewRootCommand(
8788
clients APIClients,
8889
version string,
8990
useConfigFile bool,
91+
isTerminal func() bool,
9092
) (*RootCmd, error) {
9193
cmd := &cobra.Command{
9294
Use: "ldcli",
@@ -188,10 +190,17 @@ func NewRootCommand(
188190
return nil, err
189191
}
190192

193+
// When stdout is not a TTY (e.g. piped, CI, agent), default to JSON.
194+
// FORCE_TTY: any non-empty value treats stdout as a terminal (like NO_COLOR convention).
195+
defaultOutput := "plaintext"
196+
if os.Getenv("FORCE_TTY") == "" && (isTerminal == nil || !isTerminal()) {
197+
defaultOutput = "json"
198+
}
199+
191200
cmd.PersistentFlags().StringP(
192201
cliflags.OutputFlag,
193202
"o",
194-
"plaintext",
203+
defaultOutput,
195204
cliflags.OutputFlagDescription,
196205
)
197206
err = viper.BindPFlag(cliflags.OutputFlag, cmd.PersistentFlags().Lookup(cliflags.OutputFlag))
@@ -252,6 +261,7 @@ func Execute(version string) {
252261
clients,
253262
version,
254263
true,
264+
func() bool { return term.IsTerminal(int(os.Stdout.Fd())) },
255265
)
256266
if err != nil {
257267
log.Fatal(err)

cmd/root_test.go

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package cmd_test
22

33
import (
4+
"bytes"
5+
"io"
6+
"os"
47
"testing"
58

69
"github.com/stretchr/testify/assert"
710
"github.com/stretchr/testify/require"
811

912
"github.com/launchdarkly/ldcli/cmd"
13+
"github.com/launchdarkly/ldcli/cmd/cliflags"
1014
"github.com/launchdarkly/ldcli/internal/analytics"
15+
"github.com/launchdarkly/ldcli/internal/config"
1116
"github.com/launchdarkly/ldcli/internal/resources"
1217
)
1318

@@ -29,7 +34,7 @@ func TestCreate(t *testing.T) {
2934
})
3035
}
3136

32-
func TestJSONFlag(t *testing.T) {
37+
func TestOutputFlags(t *testing.T) {
3338
mockClient := &resources.MockClient{
3439
Response: []byte(`{"key": "test-key", "name": "test-name"}`),
3540
}
@@ -55,4 +60,162 @@ func TestJSONFlag(t *testing.T) {
5560
assert.Contains(t, string(output), `"key": "test-key"`)
5661
assert.NotContains(t, string(output), "Successfully updated")
5762
})
63+
64+
t.Run("--output json returns raw JSON output", func(t *testing.T) {
65+
args := []string{
66+
"flags", "toggle-on",
67+
"--access-token", "abcd1234",
68+
"--environment", "test-env",
69+
"--flag", "test-flag",
70+
"--project", "test-proj",
71+
"--output", "json",
72+
}
73+
74+
output, err := cmd.CallCmd(
75+
t,
76+
cmd.APIClients{ResourcesClient: mockClient},
77+
analytics.NoopClientFn{}.Tracker(),
78+
args,
79+
)
80+
81+
require.NoError(t, err)
82+
assert.Contains(t, string(output), `"key": "test-key"`)
83+
assert.NotContains(t, string(output), "Successfully updated")
84+
})
85+
86+
t.Run("--output plaintext returns human-readable output", func(t *testing.T) {
87+
args := []string{
88+
"flags", "toggle-on",
89+
"--access-token", "abcd1234",
90+
"--environment", "test-env",
91+
"--flag", "test-flag",
92+
"--project", "test-proj",
93+
"--output", "plaintext",
94+
}
95+
96+
output, err := cmd.CallCmd(
97+
t,
98+
cmd.APIClients{ResourcesClient: mockClient},
99+
analytics.NoopClientFn{}.Tracker(),
100+
args,
101+
)
102+
103+
require.NoError(t, err)
104+
assert.Contains(t, string(output), "Successfully updated")
105+
assert.Contains(t, string(output), "test-name (test-key)")
106+
})
107+
}
108+
109+
func newRootCmdWithTerminal(t *testing.T, isTerminal func() bool) *cmd.RootCmd {
110+
t.Helper()
111+
rootCmd, err := cmd.NewRootCommand(
112+
config.NewService(&resources.MockClient{}),
113+
analytics.NoopClientFn{}.Tracker(),
114+
cmd.APIClients{},
115+
"test",
116+
false,
117+
isTerminal,
118+
)
119+
require.NoError(t, err)
120+
return rootCmd
121+
}
122+
123+
func execNonTTYCmd(t *testing.T, mockClient *resources.MockClient, extraArgs ...string) []byte {
124+
t.Helper()
125+
rootCmd, err := cmd.NewRootCommand(
126+
config.NewService(&resources.MockClient{}),
127+
analytics.NoopClientFn{}.Tracker(),
128+
cmd.APIClients{ResourcesClient: mockClient},
129+
"test",
130+
false,
131+
func() bool { return false },
132+
)
133+
require.NoError(t, err)
134+
135+
c := rootCmd.Cmd()
136+
b := bytes.NewBufferString("")
137+
c.SetOut(b)
138+
args := []string{
139+
"flags", "toggle-on",
140+
"--access-token", "abcd1234",
141+
"--environment", "test-env",
142+
"--flag", "test-flag",
143+
"--project", "test-proj",
144+
}
145+
args = append(args, extraArgs...)
146+
c.SetArgs(args)
147+
148+
err = c.Execute()
149+
require.NoError(t, err)
150+
151+
out, err := io.ReadAll(b)
152+
require.NoError(t, err)
153+
return out
154+
}
155+
156+
func TestTTYDefaultOutput(t *testing.T) {
157+
mockClient := &resources.MockClient{
158+
Response: []byte(`{"key": "test-key", "name": "test-name"}`),
159+
}
160+
161+
t.Run("non-TTY defaults to json output", func(t *testing.T) {
162+
rootCmd := newRootCmdWithTerminal(t, func() bool { return false })
163+
164+
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
165+
require.NotNil(t, f)
166+
assert.Equal(t, "json", f.DefValue)
167+
})
168+
169+
t.Run("TTY defaults to plaintext output", func(t *testing.T) {
170+
rootCmd := newRootCmdWithTerminal(t, func() bool { return true })
171+
172+
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
173+
require.NotNil(t, f)
174+
assert.Equal(t, "plaintext", f.DefValue)
175+
})
176+
177+
t.Run("nil isTerminal defaults to json output", func(t *testing.T) {
178+
rootCmd := newRootCmdWithTerminal(t, nil)
179+
180+
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
181+
require.NotNil(t, f)
182+
assert.Equal(t, "json", f.DefValue)
183+
})
184+
185+
t.Run("explicit --output plaintext overrides non-TTY", func(t *testing.T) {
186+
out := execNonTTYCmd(t, mockClient, "--output", "plaintext")
187+
assert.Contains(t, string(out), "Successfully updated")
188+
})
189+
190+
t.Run("non-TTY without explicit flag returns JSON", func(t *testing.T) {
191+
out := execNonTTYCmd(t, mockClient)
192+
assert.Contains(t, string(out), `"key"`)
193+
assert.NotContains(t, string(out), "Successfully updated")
194+
})
195+
196+
t.Run("--json overrides non-TTY default", func(t *testing.T) {
197+
out := execNonTTYCmd(t, mockClient, "--json")
198+
assert.Contains(t, string(out), `"key"`)
199+
assert.NotContains(t, string(out), "Successfully updated")
200+
})
201+
202+
t.Run("LD_OUTPUT=plaintext overrides non-TTY default", func(t *testing.T) {
203+
os.Setenv("LD_OUTPUT", "plaintext")
204+
defer os.Unsetenv("LD_OUTPUT")
205+
206+
out := execNonTTYCmd(t, mockClient)
207+
assert.Contains(t, string(out), "Successfully updated")
208+
assert.Contains(t, string(out), "test-name (test-key)")
209+
})
210+
211+
t.Run("FORCE_TTY overrides non-TTY detection", func(t *testing.T) {
212+
os.Setenv("FORCE_TTY", "1")
213+
defer os.Unsetenv("FORCE_TTY")
214+
215+
rootCmd := newRootCmdWithTerminal(t, func() bool { return false })
216+
217+
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
218+
require.NotNil(t, f)
219+
assert.Equal(t, "plaintext", f.DefValue)
220+
})
58221
}

internal/output/output_test.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
11
package output_test
22

33
import (
4-
"github.com/launchdarkly/ldcli/internal/output"
54
"testing"
65

76
"github.com/stretchr/testify/assert"
87
"github.com/stretchr/testify/require"
8+
9+
"github.com/launchdarkly/ldcli/internal/output"
910
)
1011

12+
func TestNewOutputKind(t *testing.T) {
13+
t.Run("returns json for valid json input", func(t *testing.T) {
14+
kind, err := output.NewOutputKind("json")
15+
require.NoError(t, err)
16+
assert.Equal(t, output.OutputKindJSON, kind)
17+
})
18+
19+
t.Run("returns plaintext for valid plaintext input", func(t *testing.T) {
20+
kind, err := output.NewOutputKind("plaintext")
21+
require.NoError(t, err)
22+
assert.Equal(t, output.OutputKindPlaintext, kind)
23+
})
24+
25+
t.Run("returns error for invalid input", func(t *testing.T) {
26+
kind, err := output.NewOutputKind("xml")
27+
assert.ErrorIs(t, err, output.ErrInvalidOutputKind)
28+
assert.Equal(t, output.OutputKindNull, kind)
29+
})
30+
31+
t.Run("returns error for empty string", func(t *testing.T) {
32+
kind, err := output.NewOutputKind("")
33+
assert.ErrorIs(t, err, output.ErrInvalidOutputKind)
34+
assert.Equal(t, output.OutputKindNull, kind)
35+
})
36+
}
37+
1138
func TestCmdOutputSingular(t *testing.T) {
1239
tests := map[string]struct {
1340
expected string
@@ -63,4 +90,17 @@ func TestCmdOutputSingular(t *testing.T) {
6390
assert.Equal(t, tt.expected, output)
6491
})
6592
}
93+
94+
t.Run("with json output kind returns raw JSON", func(t *testing.T) {
95+
input := `{"key": "test-key", "name": "test-name"}`
96+
97+
result, err := output.CmdOutputSingular(
98+
"json",
99+
[]byte(input),
100+
output.SingularPlaintextOutputFn,
101+
)
102+
103+
require.NoError(t, err)
104+
assert.JSONEq(t, input, result)
105+
})
66106
}

internal/output/resource_output_test.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,54 @@ func TestCmdOutputError(t *testing.T) {
310310
assert.JSONEq(t, expected, result)
311311
})
312312

313-
t.Run("with plaintext output", func(t *testing.T) {
314-
expected := "invalid JSON"
313+
t.Run("with plaintext output returns formatted message", func(t *testing.T) {
314+
err := errors.NewError(`{"code":"conflict", "message":"an error"}`)
315+
316+
result := output.CmdOutputError("plaintext", err)
317+
318+
assert.Equal(t, "an error (code: conflict)", result)
319+
})
320+
})
321+
322+
t.Run("with a json.UnmarshalTypeError", func(t *testing.T) {
323+
t.Run("with plaintext output returns invalid JSON", func(t *testing.T) {
324+
type testType any
325+
invalid := []byte(`{"invalid": true}`)
326+
err := json.Unmarshal(invalid, &[]testType{})
327+
require.Error(t, err)
328+
329+
result := output.CmdOutputError("plaintext", err)
330+
331+
assert.Equal(t, "invalid JSON", result)
332+
})
333+
334+
t.Run("with JSON output returns invalid JSON message", func(t *testing.T) {
315335
type testType any
316336
invalid := []byte(`{"invalid": true}`)
317337
err := json.Unmarshal(invalid, &[]testType{})
318338
require.Error(t, err)
319339

340+
result := output.CmdOutputError("json", err)
341+
342+
assert.JSONEq(t, `{"message":"invalid JSON"}`, result)
343+
})
344+
})
345+
346+
t.Run("with a generic error", func(t *testing.T) {
347+
t.Run("with JSON output wraps message in JSON", func(t *testing.T) {
348+
err := fmt.Errorf("something went wrong")
349+
350+
result := output.CmdOutputError("json", err)
351+
352+
assert.JSONEq(t, `{"message":"something went wrong"}`, result)
353+
})
354+
355+
t.Run("with plaintext output returns message", func(t *testing.T) {
356+
err := fmt.Errorf("something went wrong")
357+
320358
result := output.CmdOutputError("plaintext", err)
321359

322-
assert.Equal(t, expected, result)
360+
assert.Equal(t, "something went wrong", result)
323361
})
324362
})
325363
}

0 commit comments

Comments
 (0)