Skip to content

Commit 7a8c18c

Browse files
committed
improvements
1 parent b07eac9 commit 7a8c18c

5 files changed

Lines changed: 135 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### ⚠ BREAKING CHANGES
6+
7+
* When stdout is not a TTY, the default `--output` format is now **json** instead of plaintext. Scripts that assumed plaintext when output was piped or redirected should set `LD_OUTPUT=plaintext`, run `ldcli config --set output plaintext`, or pass `--output plaintext` (or `--output json` explicitly if you want JSON regardless of TTY).
8+
39
## [2.2.0](https://github.com/launchdarkly/ldcli/compare/v2.1.0...v2.2.0) (2026-02-20)
410

511

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ ldcli config --set access-token api-00000000-0000-0000-0000-000000000000
9090

9191
Running this command creates a configuration file located at `$XDG_CONFIG_HOME/ldcli/config.yml` with the access token. Subsequent commands read from this file, so you do not need to specify the access token each time.
9292

93+
### Output format defaults
94+
95+
When you do not pass `--output` or `--json`, the default format depends on whether standard output is a terminal: **plaintext** in an interactive terminal, **json** when stdout is not a TTY (for example when piped, in CI, or in agent environments).
96+
97+
To force the plaintext default even when stdout is not a TTY, set either **`FORCE_TTY`** or **`LD_FORCE_TTY`** to any non-empty value (similar to tools that use `NO_COLOR`). That only affects the default; explicit `--output`, `--json`, `LD_OUTPUT`, and the `output` setting in your config file still apply.
98+
99+
Effective output is resolved in this order: **`--json`** and **`--output`** flags, then **`LD_OUTPUT`**, then the **`output`** value from your config file, then the TTY-based default above.
100+
93101
## Commands
94102

95103
LaunchDarkly CLI commands:

cmd/cmdtest.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ var StubbedSuccessResponse = `{
1919
"name": "test-name"
2020
}`
2121

22+
// CallCmd runs the root command for integration-style tests. It passes isTerminal always true so
23+
// the default --output matches an interactive terminal (plaintext); non-TTY JSON defaults are
24+
// covered in root_test.go.
2225
func CallCmd(
2326
t *testing.T,
2427
clients APIClients,

cmd/root.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ func init() {
8282
cobra.AddTemplateFunc("HasOptionalFlags", HasOptionalFlags)
8383
}
8484

85+
// forceTTYDefaultOutput is true when FORCE_TTY or LD_FORCE_TTY is non-empty, so the default
86+
// --output is plaintext even if stdout is not a TTY (similar to NO_COLOR). Explicit --output,
87+
// --json, LD_OUTPUT, and config file values still take precedence via Viper/Cobra after parse.
88+
func forceTTYDefaultOutput() bool {
89+
return os.Getenv("FORCE_TTY") != "" || os.Getenv("LD_FORCE_TTY") != ""
90+
}
91+
92+
// NewRootCommand constructs the ldcli root command tree.
93+
//
94+
// isTerminal should reflect whether stdout is a TTY (see Execute). For nil or a function that
95+
// returns false, the default --output is json unless forceTTYDefaultOutput applies—intended for
96+
// tests and embeddings; production should always pass a non-nil detector.
8597
func NewRootCommand(
8698
configService config.Service,
8799
analyticsTrackerFn analytics.TrackerFn,
@@ -190,10 +202,10 @@ func NewRootCommand(
190202
return nil, err
191203
}
192204

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).
205+
// When stdout is not a TTY (e.g. piped, CI, agent), default to JSON unless FORCE_TTY or
206+
// LD_FORCE_TTY is set (any non-empty value), like NO_COLOR.
195207
defaultOutput := "plaintext"
196-
if os.Getenv("FORCE_TTY") == "" && (isTerminal == nil || !isTerminal()) {
208+
if !forceTTYDefaultOutput() && (isTerminal == nil || !isTerminal()) {
197209
defaultOutput = "json"
198210
}
199211

cmd/root_test.go

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"bytes"
55
"io"
66
"os"
7+
"path/filepath"
78
"testing"
89

10+
"github.com/spf13/viper"
911
"github.com/stretchr/testify/assert"
1012
"github.com/stretchr/testify/require"
1113

@@ -153,12 +155,15 @@ func execNonTTYCmd(t *testing.T, mockClient *resources.MockClient, extraArgs ...
153155
return out
154156
}
155157

156-
func TestTTYDefaultOutput(t *testing.T) {
158+
func TestOutputDefaultsAndOverrides(t *testing.T) {
157159
mockClient := &resources.MockClient{
158160
Response: []byte(`{"key": "test-key", "name": "test-name"}`),
159161
}
160162

161163
t.Run("non-TTY defaults to json output", func(t *testing.T) {
164+
t.Setenv("FORCE_TTY", "")
165+
t.Setenv("LD_FORCE_TTY", "")
166+
162167
rootCmd := newRootCmdWithTerminal(t, func() bool { return false })
163168

164169
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
@@ -167,6 +172,9 @@ func TestTTYDefaultOutput(t *testing.T) {
167172
})
168173

169174
t.Run("TTY defaults to plaintext output", func(t *testing.T) {
175+
t.Setenv("FORCE_TTY", "")
176+
t.Setenv("LD_FORCE_TTY", "")
177+
170178
rootCmd := newRootCmdWithTerminal(t, func() bool { return true })
171179

172180
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
@@ -175,6 +183,9 @@ func TestTTYDefaultOutput(t *testing.T) {
175183
})
176184

177185
t.Run("nil isTerminal defaults to json output", func(t *testing.T) {
186+
t.Setenv("FORCE_TTY", "")
187+
t.Setenv("LD_FORCE_TTY", "")
188+
178189
rootCmd := newRootCmdWithTerminal(t, nil)
179190

180191
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
@@ -183,39 +194,125 @@ func TestTTYDefaultOutput(t *testing.T) {
183194
})
184195

185196
t.Run("explicit --output plaintext overrides non-TTY", func(t *testing.T) {
197+
t.Setenv("FORCE_TTY", "")
198+
t.Setenv("LD_FORCE_TTY", "")
199+
186200
out := execNonTTYCmd(t, mockClient, "--output", "plaintext")
187201
assert.Contains(t, string(out), "Successfully updated")
188202
})
189203

190204
t.Run("non-TTY without explicit flag returns JSON", func(t *testing.T) {
205+
t.Setenv("FORCE_TTY", "")
206+
t.Setenv("LD_FORCE_TTY", "")
207+
191208
out := execNonTTYCmd(t, mockClient)
192209
assert.Contains(t, string(out), `"key"`)
193210
assert.NotContains(t, string(out), "Successfully updated")
194211
})
195212

196213
t.Run("--json overrides non-TTY default", func(t *testing.T) {
214+
t.Setenv("FORCE_TTY", "")
215+
t.Setenv("LD_FORCE_TTY", "")
216+
197217
out := execNonTTYCmd(t, mockClient, "--json")
198218
assert.Contains(t, string(out), `"key"`)
199219
assert.NotContains(t, string(out), "Successfully updated")
200220
})
201221

202222
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")
223+
t.Setenv("LD_OUTPUT", "plaintext")
224+
t.Setenv("FORCE_TTY", "")
225+
t.Setenv("LD_FORCE_TTY", "")
205226

206227
out := execNonTTYCmd(t, mockClient)
207228
assert.Contains(t, string(out), "Successfully updated")
208229
assert.Contains(t, string(out), "test-name (test-key)")
209230
})
210231

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")
232+
t.Run("FORCE_TTY=0 yields plaintext DefValue when non-TTY", func(t *testing.T) {
233+
t.Setenv("FORCE_TTY", "0")
234+
t.Setenv("LD_FORCE_TTY", "")
214235

215236
rootCmd := newRootCmdWithTerminal(t, func() bool { return false })
216237

217238
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
218239
require.NotNil(t, f)
219240
assert.Equal(t, "plaintext", f.DefValue)
220241
})
242+
243+
t.Run("FORCE_TTY=1 yields plaintext DefValue when non-TTY", func(t *testing.T) {
244+
t.Setenv("FORCE_TTY", "1")
245+
t.Setenv("LD_FORCE_TTY", "")
246+
247+
rootCmd := newRootCmdWithTerminal(t, func() bool { return false })
248+
249+
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
250+
require.NotNil(t, f)
251+
assert.Equal(t, "plaintext", f.DefValue)
252+
})
253+
254+
t.Run("LD_FORCE_TTY=1 yields plaintext DefValue when non-TTY", func(t *testing.T) {
255+
t.Setenv("FORCE_TTY", "")
256+
t.Setenv("LD_FORCE_TTY", "1")
257+
258+
rootCmd := newRootCmdWithTerminal(t, func() bool { return false })
259+
260+
f := rootCmd.Cmd().PersistentFlags().Lookup(cliflags.OutputFlag)
261+
require.NotNil(t, f)
262+
assert.Equal(t, "plaintext", f.DefValue)
263+
})
264+
265+
t.Run("LD_FORCE_TTY=1 yields plaintext output when non-TTY", func(t *testing.T) {
266+
t.Setenv("FORCE_TTY", "")
267+
t.Setenv("LD_FORCE_TTY", "1")
268+
269+
out := execNonTTYCmd(t, mockClient)
270+
assert.Contains(t, string(out), "Successfully updated")
271+
assert.Contains(t, string(out), "test-name (test-key)")
272+
})
273+
}
274+
275+
func TestConfigOutputPrecedenceNonTTY(t *testing.T) {
276+
viper.Reset()
277+
t.Cleanup(viper.Reset)
278+
279+
mockClient := &resources.MockClient{
280+
Response: []byte(`{"key": "test-key", "name": "test-name"}`),
281+
}
282+
283+
cfgRoot := t.TempDir()
284+
t.Setenv("XDG_CONFIG_HOME", cfgRoot)
285+
t.Setenv("FORCE_TTY", "")
286+
t.Setenv("LD_FORCE_TTY", "")
287+
288+
ldcliDir := filepath.Join(cfgRoot, "ldcli")
289+
require.NoError(t, os.MkdirAll(ldcliDir, 0o755))
290+
require.NoError(t, os.WriteFile(filepath.Join(ldcliDir, "config.yml"), []byte("output: plaintext\n"), 0o644))
291+
292+
rootCmd, err := cmd.NewRootCommand(
293+
config.NewService(&resources.MockClient{}),
294+
analytics.NoopClientFn{}.Tracker(),
295+
cmd.APIClients{ResourcesClient: mockClient},
296+
"test",
297+
true,
298+
func() bool { return false },
299+
)
300+
require.NoError(t, err)
301+
302+
c := rootCmd.Cmd()
303+
b := bytes.NewBufferString("")
304+
c.SetOut(b)
305+
c.SetArgs([]string{
306+
"flags", "toggle-on",
307+
"--access-token", "abcd1234",
308+
"--environment", "test-env",
309+
"--flag", "test-flag",
310+
"--project", "test-proj",
311+
})
312+
require.NoError(t, c.Execute())
313+
314+
out, err := io.ReadAll(b)
315+
require.NoError(t, err)
316+
assert.Contains(t, string(out), "Successfully updated")
317+
assert.Contains(t, string(out), "test-name (test-key)")
221318
}

0 commit comments

Comments
 (0)