Skip to content

Commit 7d80e0c

Browse files
committed
Write diagnostic warnings to stderr instead of stdout
Expiration warnings, profile-not-found warnings, and config permission warnings were written to data.Output (stdout), breaking JSON parsing when --json was used.
1 parent f2730b1 commit 7d80e0c

8 files changed

Lines changed: 103 additions & 45 deletions

File tree

pkg/app/expiry_warning_test.go

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ import (
1818
func expiringTokenData(out *bytes.Buffer) *global.Data {
1919
soon := time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
2020
return &global.Data{
21-
Output: out,
22-
ErrLog: fsterr.Log,
23-
Manifest: &manifest.Data{},
21+
Output: out,
22+
ErrOutput: out,
23+
ErrLog: fsterr.Log,
24+
Manifest: &manifest.Data{},
2425
Config: config.File{
2526
Auth: config.Auth{
2627
Default: "mytoken",
@@ -52,8 +53,9 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
5253
commandName: "service list",
5354
data: func(out *bytes.Buffer) *global.Data {
5455
return &global.Data{
55-
Output: out,
56-
ErrLog: fsterr.Log,
56+
Output: out,
57+
ErrOutput: out,
58+
ErrLog: fsterr.Log,
5759
Config: config.File{
5860
Auth: config.Auth{
5961
Default: "mytoken",
@@ -76,8 +78,9 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
7678
commandName: "service list",
7779
data: func(out *bytes.Buffer) *global.Data {
7880
return &global.Data{
79-
Output: out,
80-
ErrLog: fsterr.Log,
81+
Output: out,
82+
ErrOutput: out,
83+
ErrLog: fsterr.Log,
8184
Config: config.File{
8285
Auth: config.Auth{
8386
Default: "mytoken",
@@ -99,9 +102,10 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
99102
commandName: "service list",
100103
data: func(out *bytes.Buffer) *global.Data {
101104
return &global.Data{
102-
Output: out,
103-
ErrLog: fsterr.Log,
104-
Env: config.Environment{APIToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fake"},
105+
Output: out,
106+
ErrOutput: out,
107+
ErrLog: fsterr.Log,
108+
Env: config.Environment{APIToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.fake"},
105109
Config: config.File{
106110
Auth: config.Auth{
107111
Default: "mytoken",
@@ -123,9 +127,10 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
123127
commandName: "service list",
124128
data: func(out *bytes.Buffer) *global.Data {
125129
return &global.Data{
126-
Output: out,
127-
ErrLog: fsterr.Log,
128-
Flags: global.Flags{Token: "some-raw-token"},
130+
Output: out,
131+
ErrOutput: out,
132+
ErrLog: fsterr.Log,
133+
Flags: global.Flags{Token: "some-raw-token"},
129134
Config: config.File{
130135
Auth: config.Auth{
131136
Default: "mytoken",
@@ -147,8 +152,9 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
147152
commandName: "service list",
148153
data: func(out *bytes.Buffer) *global.Data {
149154
return &global.Data{
150-
Output: out,
151-
ErrLog: fsterr.Log,
155+
Output: out,
156+
ErrOutput: out,
157+
ErrLog: fsterr.Log,
152158
Config: config.File{
153159
Auth: config.Auth{
154160
Default: "deleted-token",
@@ -164,8 +170,9 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
164170
commandName: "service list",
165171
data: func(out *bytes.Buffer) *global.Data {
166172
return &global.Data{
167-
Output: out,
168-
ErrLog: fsterr.Log,
173+
Output: out,
174+
ErrOutput: out,
175+
ErrLog: fsterr.Log,
169176
Config: config.File{
170177
Auth: config.Auth{
171178
Default: "mytoken",
@@ -187,7 +194,8 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
187194
commandName: "service list",
188195
data: func(out *bytes.Buffer) *global.Data {
189196
return &global.Data{
190-
Output: out,
197+
Output: out,
198+
ErrOutput: out,
191199
Config: config.File{
192200
Auth: config.Auth{
193201
Default: "mytoken",
@@ -209,8 +217,9 @@ func TestCheckTokenExpirationWarning(t *testing.T) {
209217
commandName: "service list",
210218
data: func(out *bytes.Buffer) *global.Data {
211219
return &global.Data{
212-
Output: out,
213-
ErrLog: fsterr.Log,
220+
Output: out,
221+
ErrOutput: out,
222+
ErrLog: fsterr.Log,
214223
Config: config.File{
215224
Auth: config.Auth{
216225
Default: "sso-tok",
@@ -338,11 +347,6 @@ func TestCheckTokenExpirationWarningSuppression(t *testing.T) {
338347
commandName: "service list",
339348
flags: global.Flags{Quiet: true},
340349
},
341-
{
342-
name: "suppressed with --json flag (sets Quiet)",
343-
commandName: "service list",
344-
flags: global.Flags{Quiet: true}, // --json sets Quiet=true in Exec
345-
},
346350
}
347351

348352
originalEnv := os.Getenv("FASTLY_DISABLE_AUTH_COMMAND")
@@ -365,6 +369,21 @@ func TestCheckTokenExpirationWarningSuppression(t *testing.T) {
365369
}
366370
}
367371

372+
// TestCheckTokenExpirationWarningShownForJSON verifies that --json mode still
373+
// emits the warning (to stderr) rather than suppressing it entirely.
374+
func TestCheckTokenExpirationWarningShownForJSON(t *testing.T) {
375+
var buf bytes.Buffer
376+
data := expiringTokenData(&buf)
377+
data.Flags = global.Flags{JSON: true}
378+
379+
checkTokenExpirationWarning(data, "service list")
380+
381+
output := buf.String()
382+
if !strings.Contains(output, "expires in") {
383+
t.Errorf("expected expiry warning in --json mode (written to stderr), got: %s", output)
384+
}
385+
}
386+
368387
// TestCheckTokenExpirationWarningNotSuppressedForNonAuth ensures that commands
369388
// starting with "auth" as a prefix of another word (e.g. "authtoken") are not
370389
// incorrectly suppressed.

pkg/app/run.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ var Init = func(args []string, stdin io.Reader) (*global.Data, error) {
172172
ConfigPath: config.FilePath,
173173
Env: e,
174174
ErrLog: fsterr.Log,
175+
ErrOutput: os.Stderr,
175176
ExecuteWasmTools: compute.ExecuteWasmTools,
176177
HTTPClient: httpClient,
177178
Manifest: &md,
@@ -200,9 +201,11 @@ func Exec(data *global.Data) error {
200201
return err
201202
}
202203

203-
// Check for --json flag early and set quiet mode if found.
204+
// Check for --json flag early. JSON mode suppresses stdout-bound noise
205+
// (metadata notices, update checks) but still allows stderr warnings
206+
// (token expiry, profile mismatch) so they don't corrupt JSON output.
204207
if slices.Contains(data.Args, "--json") {
205-
data.Flags.Quiet = true
208+
data.Flags.JSON = true
206209
}
207210

208211
// We short-circuit the execution for specific cases:
@@ -219,7 +222,7 @@ func Exec(data *global.Data) error {
219222
}
220223

221224
metadataDisable, _ := strconv.ParseBool(data.Env.WasmMetadataDisable)
222-
if !slices.Contains(data.Args, "--metadata-disable") && !metadataDisable && !data.Config.CLI.MetadataNoticeDisplayed && commandCollectsData(commandName) && !data.Flags.Quiet {
225+
if !slices.Contains(data.Args, "--metadata-disable") && !metadataDisable && !data.Config.CLI.MetadataNoticeDisplayed && commandCollectsData(commandName) && !data.Flags.Quiet && !data.Flags.JSON {
223226
text.Important(data.Output, "The Fastly CLI is configured to collect data related to Wasm builds (e.g. compilation times, resource usage, and other non-identifying data). To learn more about what data is being collected, why, and how to disable it: https://www.fastly.com/documentation/reference/cli")
224227
text.Break(data.Output)
225228
data.Config.CLI.MetadataNoticeDisplayed = true
@@ -230,7 +233,7 @@ func Exec(data *global.Data) error {
230233
time.Sleep(5 * time.Second) // this message is only displayed once so give the user a chance to see it before it possibly scrolls off screen
231234
}
232235

233-
if data.Flags.Quiet {
236+
if data.Flags.Quiet || data.Flags.JSON {
234237
data.Manifest.File.SetQuiet(true)
235238
}
236239

@@ -287,9 +290,9 @@ func Exec(data *global.Data) error {
287290
if !data.Flags.Quiet && data.Flags.Token == "" && data.Env.APIToken == "" && data.Manifest != nil && data.Manifest.File.Profile != "" {
288291
if data.Config.GetAuthToken(data.Manifest.File.Profile) == nil {
289292
if defaultName, _ := data.Config.GetDefaultAuthToken(); defaultName != "" {
290-
text.Warning(data.Output, "fastly.toml profile %q not found in auth config, using default token %q.\n", data.Manifest.File.Profile, defaultName)
293+
text.Warning(data.ErrOutput, "fastly.toml profile %q not found in auth config, using default token %q.\n", data.Manifest.File.Profile, defaultName)
291294
} else {
292-
text.Warning(data.Output, "fastly.toml profile %q not found in auth config and no default token is configured.\n", data.Manifest.File.Profile)
295+
text.Warning(data.ErrOutput, "fastly.toml profile %q not found in auth config and no default token is configured.\n", data.Manifest.File.Profile)
293296
}
294297
}
295298
}
@@ -316,7 +319,7 @@ func Exec(data *global.Data) error {
316319
displayToken(tokenSource, data)
317320
}
318321
if !data.Flags.Quiet {
319-
checkConfigPermissions(tokenSource, data.Output)
322+
checkConfigPermissions(tokenSource, data.ErrOutput)
320323
}
321324

322325
data.APIClient, data.RTSClient, err = configureClients(token, apiEndpoint, data.APIClientFactory, data.Flags.Debug)
@@ -328,7 +331,7 @@ func Exec(data *global.Data) error {
328331

329332
checkTokenExpirationWarning(data, commandName)
330333

331-
f := checkForUpdates(data.Versioners.CLI, commandName, data.Flags.Quiet)
334+
f := checkForUpdates(data.Versioners.CLI, commandName, data.Flags.Quiet || data.Flags.JSON)
332335
defer f(data.Output)
333336

334337
return command.Exec(data.Input, data.Output)
@@ -506,9 +509,10 @@ func checkAndRefreshAuthSSOToken(name string, at *config.AuthToken, data *global
506509
// This matches the set hidden by FASTLY_DISABLE_AUTH_COMMAND (pkg/env/env.go).
507510
var authRelatedCommands = []string{"auth", "auth-token", "sso", "profile", "whoami"}
508511

509-
// checkTokenExpirationWarning prints a warning if the active stored token is
510-
// about to expire. Only fires for SourceAuth tokens; env/flag tokens are opaque.
511-
// Suppressed for auth-related commands and when --quiet or --json is active.
512+
// checkTokenExpirationWarning prints a warning to stderr if the active stored
513+
// token is about to expire. Only fires for SourceAuth tokens; env/flag tokens
514+
// are opaque. Suppressed for auth-related commands and when --quiet is active.
515+
// In --json mode the warning still fires (written to stderr via data.ErrOutput).
512516
func checkTokenExpirationWarning(data *global.Data, commandName string) {
513517
if data.Flags.Quiet {
514518
return
@@ -545,7 +549,7 @@ func checkTokenExpirationWarning(data *global.Data, commandName string) {
545549
if at.RefreshExpiresAt != "" {
546550
label = "session "
547551
}
548-
text.Warning(data.Output, "Your active token %s%s. %s\n", label, summary, remediation)
552+
text.Warning(data.ErrOutput, "Your active token %s%s. %s\n", label, summary, remediation)
549553
}
550554

551555
// isAuthRelatedCommand reports whether commandName belongs to an auth-related

pkg/app/run_test.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,7 @@ whoami
133133
}
134134

135135
// TestExecQuietSuppressesExpiryWarning exercises the full Exec path to verify
136-
// that --quiet suppresses the expiration warning end-to-end. (--json also sets
137-
// Quiet=true at run.go:204, but config doesn't accept --json; the unit test
138-
// TestCheckTokenExpirationWarningSuppression covers the Quiet flag directly.)
136+
// that --quiet suppresses the expiration warning end-to-end.
139137
func TestExecQuietSuppressesExpiryWarning(t *testing.T) {
140138
var stdout bytes.Buffer
141139

@@ -179,6 +177,38 @@ func TestExecConfigShowsExpiryWarning(t *testing.T) {
179177
}
180178
}
181179

180+
// TestExecJSONLeavesStdoutCleanAndWritesWarningToStderr verifies that in
181+
// --json mode, the expiry warning is written to stderr (not stdout) so it
182+
// does not corrupt JSON output. Because the config command does not register
183+
// --json as a flag, we simulate the effect by pre-setting Flags.JSON (which
184+
// is what Exec does when it sees --json in the args).
185+
func TestExecJSONLeavesStdoutCleanAndWritesWarningToStderr(t *testing.T) {
186+
var (
187+
stdout bytes.Buffer
188+
stderr bytes.Buffer
189+
)
190+
191+
args := testutil.SplitArgs("config -l")
192+
app.Init = func(_ []string, _ io.Reader) (*global.Data, error) {
193+
data := testutil.MockGlobalData(args, &stdout)
194+
data.ErrOutput = &stderr
195+
data.Flags.JSON = true
196+
data.Config.Auth.Tokens["user"].APITokenExpiresAt = time.Now().Add(3 * 24 * time.Hour).Format(time.RFC3339)
197+
return data, nil
198+
}
199+
err := app.Run(args, nil)
200+
if err != nil {
201+
t.Fatalf("app.Run returned unexpected error: %v", err)
202+
}
203+
204+
if strings.Contains(stdout.String(), "expires in") {
205+
t.Errorf("expected stdout free of expiry warning, got: %s", stdout.String())
206+
}
207+
if !strings.Contains(stderr.String(), "expires in") {
208+
t.Errorf("expected expiry warning on stderr, got: %s", stderr.String())
209+
}
210+
}
211+
182212
// stripTrailingSpace removes any trailing spaces from the multiline str.
183213
func stripTrailingSpace(str string) string {
184214
buf := bytes.NewBuffer(nil)

pkg/commands/authtoken/describe.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type DescribeCommand struct {
3535

3636
// Exec invokes the application logic for the command.
3737
func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error {
38-
if !c.Globals.Flags.Quiet {
38+
if !c.Globals.Flags.Quiet && !c.JSONOutput.Enabled {
3939
text.Deprecated(out, "The 'auth-token' command tree will be removed in a future release. Use the Fastly API directly to manage API tokens.\n\n")
4040
}
4141

pkg/commands/authtoken/list.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ type ListCommand struct {
4343

4444
// Exec invokes the application logic for the command.
4545
func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error {
46-
if !c.Globals.Flags.Quiet {
46+
if !c.Globals.Flags.Quiet && !c.JSONOutput.Enabled {
4747
text.Deprecated(out, "The 'auth-token' command tree will be removed in a future release. Use the Fastly API directly to manage API tokens.\n\n")
4848
}
4949

@@ -57,7 +57,7 @@ func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error {
5757
)
5858

5959
if err = c.customerID.Parse(); err == nil {
60-
if !c.customerID.WasSet && !c.Globals.Flags.Quiet {
60+
if !c.customerID.WasSet && !c.Globals.Flags.Quiet && !c.JSONOutput.Enabled {
6161
text.Info(out, "Listing customer tokens for the FASTLY_CUSTOMER_ID environment variable\n\n")
6262
}
6363
input := c.constructInput()

pkg/commands/profile/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand {
2828

2929
// Exec invokes the application logic for the command.
3030
func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error {
31-
if !c.Globals.Flags.Quiet {
31+
if !c.Globals.Flags.Quiet && !c.JSONOutput.Enabled {
3232
text.Deprecated(out, "This command will be removed in a future release. Use 'fastly auth list' instead.\n\n")
3333
}
3434

pkg/global/global.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ type Data struct {
6262
// Env is all the data that is provided by the environment.
6363
Env config.Environment
6464
// ErrLog provides an interface for recording errors to disk.
65-
ErrLog fsterr.LogInterface
65+
ErrLog fsterr.LogInterface
66+
ErrOutput io.Writer
6667
// ExecuteWasmTools is a function that executes the wasm-tools binary.
6768
ExecuteWasmTools func(bin string, args []string, global *Data) error
6869
// Flags are all the global CLI flags.
@@ -204,6 +205,9 @@ type Flags struct {
204205
AutoYes bool
205206
// Debug enables the CLI's debug mode.
206207
Debug bool
208+
// JSON indicates --json output was requested. Detected automatically by
209+
// Exec. Unlike Quiet, JSON mode does not suppress stderr warnings.
210+
JSON bool
207211
// NonInteractive auto-resolves all prompts.
208212
NonInteractive bool
209213
// Profile indicates the profile to use (consequently the 'token' used).

pkg/testutil/args.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func MockGlobalData(args []string, stdout io.Writer) *global.Data {
124124
ConfigPath: configPath,
125125
Env: config.Environment{},
126126
ErrLog: errors.Log,
127+
ErrOutput: stdout,
127128
ExecuteWasmTools: func(bin string, args []string, d *global.Data) error {
128129
fmt.Printf("bin: %s\n", bin)
129130
fmt.Printf("args: %#v\n", args)

0 commit comments

Comments
 (0)