From 46d5b07d25d45fc34ecbe6de8c18f21280cabb35 Mon Sep 17 00:00:00 2001 From: Levi Whalen Date: Mon, 3 Aug 2026 14:53:45 -0600 Subject: [PATCH 1/3] add --non-interactive flag --- pkg/cmd/application/selectapp/select.go | 70 ++++++++++- pkg/cmd/application/selectapp/select_test.go | 86 +++++++++++++ pkg/cmd/auth/login/login.go | 120 ++++++++++++++++--- pkg/cmd/auth/login/login_test.go | 105 ++++++++++++++++ pkg/cmd/shared/apputil/output.go | 32 +++++ pkg/cmdutil/result_output.go | 14 +++ 6 files changed, 408 insertions(+), 19 deletions(-) create mode 100644 pkg/cmd/shared/apputil/output.go diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index 34661c5c..d34dd536 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -24,13 +24,20 @@ type SelectOptions struct { AppID string AppName string + // NonInteractive disables every prompt and defaults the output to JSON, so + // the command is usable from scripts. + NonInteractive bool + + PrintFlags *cmdutil.PrintFlags + NewDashboardClient func(clientID string) *dashboard.Client } func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { opts := &SelectOptions{ - IO: f.IOStreams, - Config: f.Config, + IO: f.IOStreams, + Config: f.Config, + PrintFlags: cmdutil.NewPrintFlags(), NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, @@ -53,6 +60,9 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { # Select by application ID (non-interactive) $ algolia application select --app-id "ABCDEF1234" + + # Select from a script: no prompts, JSON on stdout + $ algolia application select --non-interactive --app-id "ABCDEF1234" `), Aliases: []string{"use"}, Args: validators.NoArgs(), @@ -60,8 +70,21 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { - _, err := runSelectCmd(opts) - return err + applyNonInteractive(opts) + + // Fail before authenticating: with no selector there is nothing to pick. + // A plain error, not cmdutil.FlagErrorf: a flag error makes the root + // print the usage text to stdout, which would break the JSON contract. + if opts.NonInteractive && opts.AppID == "" && opts.AppName == "" { + return fmt.Errorf("--app-id or --app-name is required in non-interactive mode") + } + + app, err := runSelectCmd(opts) + if err != nil { + return err + } + + return printSelection(opts, app) }, } @@ -70,10 +93,44 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags(). StringVar(&opts.AppName, "app-name", "", "Select application by name (non-interactive)") cmd.MarkFlagsMutuallyExclusive("app-id", "app-name") + cmd.Flags(). + BoolVar(&opts.NonInteractive, "non-interactive", false, "Never prompt; output JSON unless --output is set (requires --app-id or --app-name)") + opts.PrintFlags.AddFlags(cmd) return cmd } +// applyNonInteractive turns off every prompt and defaults the output to JSON, +// leaving an explicit --output untouched. +func applyNonInteractive(opts *SelectOptions) { + if !opts.NonInteractive { + return + } + + opts.IO.SetNeverPrompt(true) + + if opts.PrintFlags != nil && opts.PrintFlags.OutputFormat != nil && + !opts.PrintFlags.HasStructuredOutput() { + *opts.PrintFlags.OutputFormat = "json" + } +} + +// printSelection emits the structured document once the flow is done. The +// human-readable flow output has already been written to stderr by then. +func printSelection(opts *SelectOptions, app *dashboard.Application) error { + if !opts.PrintFlags.HasStructuredOutput() { + return nil + } + + if app == nil { + return fmt.Errorf( + "no applications found; create one with \"algolia application create\"", + ) + } + + return opts.PrintFlags.Print(opts.IO, apputil.NewApplicationOutput(opts.Config, app)) +} + // Run executes the interactive application-selection flow and returns the // chosen application. Other commands (e.g. open) use it to ensure an // application is selected before proceeding. A nil application is returned @@ -91,6 +148,11 @@ func Run(f *cmdutil.Factory) (*dashboard.Application, error) { } func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) { + // Drop progress narration so the command emits the JSON document only. + if opts.PrintFlags.HasStructuredOutput() { + defer cmdutil.DiscardHumanOutput(opts.IO)() + } + cs := opts.IO.ColorScheme() client := opts.NewDashboardClient(auth.OAuthClientID()) diff --git a/pkg/cmd/application/selectapp/select_test.go b/pkg/cmd/application/selectapp/select_test.go index 783156f6..ac069448 100644 --- a/pkg/cmd/application/selectapp/select_test.go +++ b/pkg/cmd/application/selectapp/select_test.go @@ -1,6 +1,7 @@ package selectapp import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -13,6 +14,8 @@ import ( "github.com/algolia/cli/api/dashboard" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/shared/apputil" + "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/keychain" "github.com/algolia/cli/test" @@ -93,6 +96,89 @@ func newSelectOptsWithSelector( return opts } +func Test_runSelectCmd_NonInteractiveWritesJSONOnlyToStdout(t *testing.T) { + createHit := false + srv := selectServer(t, &createHit) + defer srv.Close() + + cfg := &test.ConfigStub{} + opts := newSelectOpts(t, srv, cfg) + opts.NonInteractive = true + opts.PrintFlags = cmdutil.NewPrintFlags() + // Mirrors what the command does before running. + applyNonInteractive(opts) + + stdout, stderr := captureOutput(t, opts.IO) + + app, err := runSelectCmd(opts) + require.NoError(t, err) + require.NoError(t, printSelection(opts, app)) + + // The JSON document is the whole output: progress narration is dropped, not + // moved to stderr, and key material never appears. + var got apputil.ApplicationOutput + require.NoError(t, json.Unmarshal(stdout.Bytes(), &got), "stdout: %q", stdout.String()) + assert.Equal(t, apputil.ApplicationOutput{ + ID: "APP1", + Alias: "my app", + Name: "My App", + }, got) + assert.NotContains(t, stdout.String(), "API key") + assert.NotContains(t, stdout.String(), "new-key") + assert.Empty(t, stderr.String()) +} + +func Test_applyNonInteractive_DefaultsToJSON(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags()} + + // Flag off: nothing changes. + applyNonInteractive(opts) + assert.False(t, opts.PrintFlags.HasStructuredOutput()) + assert.False(t, io.GetNeverPrompt()) + + opts.NonInteractive = true + applyNonInteractive(opts) + assert.True(t, io.GetNeverPrompt()) + assert.Equal(t, "json", *opts.PrintFlags.OutputFormat) +} + +func Test_applyNonInteractive_KeepsExplicitOutput(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} + *opts.PrintFlags.OutputFormat = "jsonpath={.id}" + + applyNonInteractive(opts) + assert.Equal(t, "jsonpath={.id}", *opts.PrintFlags.OutputFormat) +} + +func TestNewSelectCmd_NonInteractiveRequiresSelector(t *testing.T) { + f, inOut := test.NewFactory(true, nil, &test.ConfigStub{}, "") + + _, err := test.Execute(NewSelectCmd(f), "--non-interactive", inOut) + assert.ErrorContains(t, err, "--app-id or --app-name is required in non-interactive mode") + assert.Empty(t, inOut.OutBuf.String()) +} + +func Test_printSelection_NoApplication(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} + applyNonInteractive(opts) + + assert.ErrorContains(t, printSelection(opts, nil), "no applications found") +} + +// captureOutput swaps the test streams for buffers we can assert on separately. +func captureOutput(t *testing.T, io *iostreams.IOStreams) (*bytes.Buffer, *bytes.Buffer) { + t.Helper() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + io.Out = stdout + io.ErrOut = stderr + + return stdout, stderr +} + func Test_runSelectCmd_RegeneratesKeyWhenNoUUID(t *testing.T) { createHit := false srv := selectServer(t, &createHit) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index a0c1c15b..1cd7ac62 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -34,14 +34,33 @@ type LoginOptions struct { // waits for the redirect. NoBrowser bool + // NonInteractive disables every prompt and defaults the output to JSON, so + // the command is usable from scripts. It signs in only: unless --app-name + // names one, choosing an application is left to `algolia application select`. + // The browser step is unaffected - the authorize URL still has to be opened + // for the flow to complete. + NonInteractive bool + + // PrintFlags is nil for callers that don't expose output flags (e.g. signup). + PrintFlags *cmdutil.PrintFlags + NewDashboardClient func(clientID string) *dashboard.Client } +// loginResult is the machine-readable outcome of the flow. Application is nil +// when the run signed in without configuring one. +type loginResult struct { + Success bool `json:"success"` + Email string `json:"email,omitempty"` + Application *apputil.ApplicationOutput `json:"application,omitempty"` +} + // NewLoginCmd returns a new instance of the login command. func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { opts := &LoginOptions{ - IO: f.IOStreams, - Config: f.Config, + IO: f.IOStreams, + Config: f.Config, + PrintFlags: cmdutil.NewPrintFlags(), NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, @@ -61,6 +80,11 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { Use --no-browser if the browser cannot be opened automatically (e.g. SSH sessions, containers). The URL will be printed for you to open manually; the CLI still waits for the redirect. + + Use --non-interactive to sign in without any prompt and print the + result as JSON. It signs in only: no application is configured unless + --app-name names one, so pick one afterwards with + "algolia application select". `), Example: heredoc.Doc(` # Sign in interactively (opens browser) @@ -71,9 +95,13 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { # Print the URL instead of opening the browser $ algolia auth login --no-browser + + # Sign in from a script: no prompts, JSON on stdout, no application + $ algolia auth login --non-interactive `), Args: validators.NoArgs(), RunE: func(cmd *cobra.Command, args []string) error { + applyNonInteractive(opts) return runLoginCmd(cmd.Context(), opts) }, } @@ -84,10 +112,28 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&opts.ProfileName, "profile-name", "", "Alias for the application (defaults to the application name)") cmd.Flags().BoolVar(&opts.Default, "default", true, "Set the application as the current one") cmd.Flags().BoolVar(&opts.NoBrowser, "no-browser", false, "Print the authorize URL instead of opening the browser") + cmd.Flags(). + BoolVar(&opts.NonInteractive, "non-interactive", false, "Sign in without prompting and print JSON; configures no application unless --app-name is set") + opts.PrintFlags.AddFlags(cmd) return cmd } +// applyNonInteractive turns off every prompt and defaults the output to JSON, +// leaving an explicit --output untouched. +func applyNonInteractive(opts *LoginOptions) { + if !opts.NonInteractive { + return + } + + opts.IO.SetNeverPrompt(true) + + if opts.PrintFlags != nil && opts.PrintFlags.OutputFormat != nil && + !opts.PrintFlags.HasStructuredOutput() { + *opts.PrintFlags.OutputFormat = "json" + } +} + func runLoginCmd(ctx context.Context, opts *LoginOptions) error { return RunOAuthFlow(ctx, opts, false) } @@ -102,9 +148,32 @@ func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error { tracker := telemetry.NewFlowTracker() telemetry.TrackEvent(ctx, telemetry.AuthStarted(flow, opts.NoBrowser)) - err := runOAuthFlowSteps(ctx, opts, signup, tracker) + app, err := runOAuthFlowSteps(ctx, opts, signup, tracker) trackOAuthFlowOutcome(ctx, flow, tracker, err) - return err + if err != nil { + return err + } + + return printLoginResult(opts, app) +} + +// printLoginResult emits the structured document once the flow is done, and +// nothing at all in the default human-readable mode. +func printLoginResult(opts *LoginOptions, app *dashboard.Application) error { + if !opts.PrintFlags.HasStructuredOutput() { + return nil + } + + result := loginResult{Success: true} + if token := auth.LoadToken(); token != nil { + result.Email = token.Email + } + if app != nil { + application := apputil.NewApplicationOutput(opts.Config, app) + result.Application = &application + } + + return opts.PrintFlags.Print(opts.IO, result) } // trackOAuthFlowOutcome reports how the auth flow ended: completed, aborted by @@ -130,24 +199,39 @@ func runOAuthFlowSteps( opts *LoginOptions, signup bool, tracker *telemetry.FlowTracker, -) error { +) (*dashboard.Application, error) { + // Drop progress narration so the command emits the JSON document only. This + // includes the authorize URL, so --non-interactive relies on the browser + // opening: pair it with --no-browser only if the URL isn't needed. + if opts.PrintFlags.HasStructuredOutput() { + defer cmdutil.DiscardHumanOutput(opts.IO)() + } + cs := opts.IO.ColorScheme() client := opts.NewDashboardClient(auth.OAuthClientID()) openBrowser := !opts.NoBrowser accessToken, err := auth.RunOAuth(opts.IO, client, signup, openBrowser, tracker) if err != nil { - return err + return nil, err } applyStoredIdentity(ctx) + // Non-interactive login authenticates only: with no name to go on there is + // nothing to pick, and creating an application (or a key) behind a script's + // back would be a side effect it never asked for. `algolia application + // select` configures one afterwards. + if opts.NonInteractive && opts.AppName == "" { + return nil, nil + } + tracker.SetStep(telemetry.StepAppsFetch) opts.IO.StartProgressIndicatorWithLabel("Fetching applications") apps, err := client.ListApplications(accessToken) opts.IO.StopProgressIndicator() if err != nil { - return err + return nil, err } var appDetails *dashboard.Application @@ -161,7 +245,7 @@ func runOAuthFlowSteps( if appName == "" && opts.IO.CanPrompt() { appName, err = apputil.PromptName() if err != nil { - return err + return nil, err } } @@ -169,14 +253,14 @@ func runOAuthFlowSteps( // stays on the app_create step. appDetails, _, err = apputil.CreateAndFetchApplication(opts.IO, client, accessToken, opts.Region, appName, nil) if err != nil { - return err + return nil, err } } else { tracker.SetStep(telemetry.StepAppSelect) interactive := opts.IO.CanPrompt() app, err := selectApplication(opts, apps, interactive) if err != nil { - return err + return nil, err } appDetails = app @@ -186,7 +270,7 @@ func runOAuthFlowSteps( _, hasUUID := opts.Config.APIKeyUUID(appDetails.ID) if !hasUUID || !apputil.ReuseExistingAPIKey(opts.Config, appDetails) { if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, appDetails); err != nil { - return err + return nil, err } } } @@ -197,7 +281,11 @@ func runOAuthFlowSteps( } tracker.SetStep(telemetry.StepProfileConfigure) - return apputil.ConfigureProfile(opts.IO, opts.Config, appDetails, profileName, opts.Default) + if err := apputil.ConfigureProfile(opts.IO, opts.Config, appDetails, profileName, opts.Default); err != nil { + return nil, err + } + + return appDetails, nil } // applyStoredIdentity copies the persisted user identity from the stored token @@ -234,11 +322,13 @@ func selectApplication(opts *LoginOptions, apps []dashboard.Application, interac } if !interactive { - fmt.Fprintf(opts.IO.Out, "Multiple applications found:\n") + // stderr: this listing explains the error below, so it must not be + // dropped or mixed into structured output. + fmt.Fprintf(opts.IO.ErrOut, "Multiple applications found:\n") for i, app := range apps { - fmt.Fprintf(opts.IO.Out, " %d. %s (%s)\n", i+1, app.Name, app.ID) + fmt.Fprintf(opts.IO.ErrOut, " %d. %s (%s)\n", i+1, app.Name, app.ID) } - fmt.Fprintf(opts.IO.Out, "Use --app-name to select one.\n") + fmt.Fprintf(opts.IO.ErrOut, "Use --app-name to select one.\n") return nil, fmt.Errorf("multiple applications found - use --app-name to select one") } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index a1a45d70..2a1105b2 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -2,6 +2,7 @@ package login import ( "context" + "encoding/json" "errors" "testing" @@ -11,6 +12,7 @@ import ( "github.com/algolia/cli/api/dashboard" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/shared/apputil" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/telemetry" @@ -178,6 +180,109 @@ func TestTrackOAuthFlowOutcome(t *testing.T) { } } +func TestApplyNonInteractive(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &LoginOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags()} + + // Flag off: nothing changes. + applyNonInteractive(opts) + assert.False(t, io.GetNeverPrompt()) + assert.False(t, opts.PrintFlags.HasStructuredOutput()) + + opts.NonInteractive = true + applyNonInteractive(opts) + assert.False(t, io.CanPrompt()) + assert.Equal(t, "json", *opts.PrintFlags.OutputFormat) +} + +func TestApplyNonInteractive_KeepsExplicitOutput(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &LoginOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} + *opts.PrintFlags.OutputFormat = "jsonpath={.application.id}" + + applyNonInteractive(opts) + assert.Equal(t, "jsonpath={.application.id}", *opts.PrintFlags.OutputFormat) +} + +// applyNonInteractive must tolerate the shared LoginOptions of `auth signup`, +// which exposes no output flags. +func TestApplyNonInteractive_NilPrintFlags(t *testing.T) { + io, _, _, _ := iostreams.Test() + opts := &LoginOptions{IO: io, NonInteractive: true} + + applyNonInteractive(opts) + assert.False(t, io.CanPrompt()) + assert.NoError(t, printLoginResult(opts, &dashboard.Application{ID: "APP1"})) +} + +func TestPrintLoginResult_JSONWithoutAPIKey(t *testing.T) { + keyring.MockInit() + t.Cleanup(auth.ClearToken) + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "access", + ExpiresIn: 3600, + User: &dashboard.User{ID: 42, Email: "user@example.com"}, + })) + + io, _, stdout, _ := iostreams.Test() + opts := &LoginOptions{ + IO: io, + Config: &test.ConfigStub{ + SavedApps: map[string]test.SavedApplication{"APP1": {Alias: "my app"}}, + }, + PrintFlags: cmdutil.NewPrintFlags(), + NonInteractive: true, + } + applyNonInteractive(opts) + + app := &dashboard.Application{ + ID: "APP1", + Name: "My App", + APIKey: "secret-key", + PlanLabel: "Grow", + } + require.NoError(t, printLoginResult(opts, app)) + + var got loginResult + require.NoError(t, json.Unmarshal(stdout.Bytes(), &got), "stdout: %q", stdout.String()) + assert.True(t, got.Success) + assert.Equal(t, "user@example.com", got.Email) + require.NotNil(t, got.Application) + assert.Equal(t, apputil.ApplicationOutput{ + ID: "APP1", + Alias: "my app", + Name: "My App", + Plan: "Grow", + }, *got.Application) + assert.NotContains(t, stdout.String(), "secret-key") +} + +// Non-interactive login signs in only, so the document reports success without +// an application. +func TestPrintLoginResult_NoApplication(t *testing.T) { + keyring.MockInit() + t.Cleanup(auth.ClearToken) + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "access", + ExpiresIn: 3600, + User: &dashboard.User{ID: 42, Email: "user@example.com"}, + })) + + io, _, stdout, stderr := iostreams.Test() + opts := &LoginOptions{ + IO: io, + Config: &test.ConfigStub{}, + PrintFlags: cmdutil.NewPrintFlags(), + NonInteractive: true, + } + applyNonInteractive(opts) + + require.NoError(t, printLoginResult(opts, nil)) + + assert.JSONEq(t, `{"success":true,"email":"user@example.com"}`, stdout.String()) + assert.Empty(t, stderr.String()) +} + func TestSelectApplication_MultipleApps_NonInteractive_NoAppName(t *testing.T) { io, _, _, _ := iostreams.Test() opts := &LoginOptions{IO: io} diff --git a/pkg/cmd/shared/apputil/output.go b/pkg/cmd/shared/apputil/output.go new file mode 100644 index 00000000..dea05aec --- /dev/null +++ b/pkg/cmd/shared/apputil/output.go @@ -0,0 +1,32 @@ +package apputil + +import ( + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/config" +) + +// ApplicationOutput is the machine-readable view of an application, shared by +// the commands that select one. Field names match +// `algolia application current --output json`. API key material is +// deliberately left out: it must never reach stdout. +type ApplicationOutput struct { + ID string `json:"id"` + Alias string `json:"alias"` + Name string `json:"name"` + Plan string `json:"plan"` +} + +// NewApplicationOutput builds the output view, reading the alias from the +// config so it reflects what was actually persisted. +func NewApplicationOutput(cfg config.IConfig, app *dashboard.Application) ApplicationOutput { + out := ApplicationOutput{ + ID: app.ID, + Name: app.Name, + Plan: app.PlanLabel, + } + if alias, ok := cfg.ApplicationAlias(app.ID); ok { + out.Alias = alias + } + + return out +} diff --git a/pkg/cmdutil/result_output.go b/pkg/cmdutil/result_output.go index 2a994b17..37146be4 100644 --- a/pkg/cmdutil/result_output.go +++ b/pkg/cmdutil/result_output.go @@ -2,6 +2,7 @@ package cmdutil import ( "fmt" + "io" "github.com/algolia/cli/pkg/iostreams" ) @@ -20,3 +21,16 @@ func PrintRunSummary( _, err := fmt.Fprintln(ios.Out, human) return err } + +// DiscardHumanOutput drops the progress narration a command writes to stdout, +// so the only thing it emits is the structured document. Diagnostics written +// straight to stderr (warnings, errors) are untouched. The returned function +// restores the original writer and must run before the document is printed. +func DiscardHumanOutput(ios *iostreams.IOStreams) func() { + original := ios.Out + ios.Out = io.Discard + + return func() { + ios.Out = original + } +} From 673f588b8d5b8b0cacfed0da4ab0d6c94cfb7a77 Mon Sep 17 00:00:00 2001 From: Levi Whalen Date: Mon, 3 Aug 2026 15:38:32 -0600 Subject: [PATCH 2/3] prevent any output during --non-interactive --- pkg/cmd/application/selectapp/select.go | 1 + pkg/cmd/application/selectapp/select_test.go | 12 ++++++++++++ pkg/cmd/auth/login/login.go | 1 + pkg/iostreams/iostreams.go | 8 ++++++++ 4 files changed, 22 insertions(+) diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index d34dd536..3355b2c4 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -108,6 +108,7 @@ func applyNonInteractive(opts *SelectOptions) { } opts.IO.SetNeverPrompt(true) + opts.IO.SetProgressIndicatorEnabled(false) if opts.PrintFlags != nil && opts.PrintFlags.OutputFormat != nil && !opts.PrintFlags.HasStructuredOutput() { diff --git a/pkg/cmd/application/selectapp/select_test.go b/pkg/cmd/application/selectapp/select_test.go index ac069448..c78e1bbf 100644 --- a/pkg/cmd/application/selectapp/select_test.go +++ b/pkg/cmd/application/selectapp/select_test.go @@ -143,6 +143,18 @@ func Test_applyNonInteractive_DefaultsToJSON(t *testing.T) { assert.Equal(t, "json", *opts.PrintFlags.OutputFormat) } +// The spinner writes to stderr even on a TTY, so a non-interactive run has to +// silence it too. +func Test_applyNonInteractive_DisablesProgressIndicator(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetProgressIndicatorEnabled(true) + opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} + + applyNonInteractive(opts) + + assert.False(t, io.GetProgressIndicatorEnabled()) +} + func Test_applyNonInteractive_KeepsExplicitOutput(t *testing.T) { io, _, _, _ := iostreams.Test() opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 1cd7ac62..2c3d91f5 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -127,6 +127,7 @@ func applyNonInteractive(opts *LoginOptions) { } opts.IO.SetNeverPrompt(true) + opts.IO.SetProgressIndicatorEnabled(false) if opts.PrintFlags != nil && opts.PrintFlags.OutputFormat != nil && !opts.PrintFlags.HasStructuredOutput() { diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 49f3a169..7a7085e0 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -229,6 +229,14 @@ func (s *IOStreams) SetNeverPrompt(v bool) { s.neverPrompt = v } +func (s *IOStreams) GetProgressIndicatorEnabled() bool { + return s.progressIndicatorEnabled +} + +func (s *IOStreams) SetProgressIndicatorEnabled(v bool) { + s.progressIndicatorEnabled = v +} + func (s *IOStreams) StartProgressIndicator() { s.StartProgressIndicatorWithLabel("") } From 8d501f8462cca46834babc81ea1306a985c9a08d Mon Sep 17 00:00:00 2001 From: Levi Whalen Date: Tue, 4 Aug 2026 13:15:56 -0600 Subject: [PATCH 3/3] fixed --non-interactive and --no-browser usage, and made other pr comment changes --- pkg/cmd/application/current/current.go | 20 ++---- pkg/cmd/application/selectapp/select.go | 27 ++------ pkg/cmd/application/selectapp/select_test.go | 44 ++----------- pkg/cmd/auth/login/login.go | 29 +++------ pkg/cmd/auth/login/login_test.go | 36 ++--------- pkg/cmd/shared/apputil/output.go | 5 +- pkg/cmdutil/result_output.go | 26 +++++--- pkg/cmdutil/result_output_test.go | 65 ++++++++++++++++++++ 8 files changed, 114 insertions(+), 138 deletions(-) create mode 100644 pkg/cmdutil/result_output_test.go diff --git a/pkg/cmd/application/current/current.go b/pkg/cmd/application/current/current.go index 48001d52..f81799ff 100644 --- a/pkg/cmd/application/current/current.go +++ b/pkg/cmd/application/current/current.go @@ -8,6 +8,7 @@ import ( "github.com/algolia/cli/api/dashboard" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/shared/apputil" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" @@ -23,13 +24,6 @@ type CurrentOptions struct { NewDashboardClient func(clientID string) *dashboard.Client } -type currentApplication struct { - ID string `json:"id"` - Alias string `json:"alias"` - Name string `json:"name"` - Plan string `json:"plan"` -} - func NewCurrentCmd(f *cmdutil.Factory) *cobra.Command { opts := &CurrentOptions{ IO: f.IOStreams, @@ -82,16 +76,12 @@ func runCurrentCmd(opts *CurrentOptions) error { ) } - current := currentApplication{ID: appID} - if alias, ok := opts.Config.ApplicationAlias(appID); ok { - current.Alias = alias - } - + // The ID and alias are shown even when the name and plan can't be fetched. app, signedOut := fetchApplication(opts, appID) - if app != nil { - current.Name = app.Name - current.Plan = app.PlanLabel + if app == nil { + app = &dashboard.Application{ID: appID} } + current := apputil.NewApplicationOutput(opts.Config, app) if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { p, err := opts.PrintFlags.ToPrinter() diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index 3355b2c4..0cb65956 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -70,11 +70,11 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { - applyNonInteractive(opts) + if opts.NonInteractive { + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + } // Fail before authenticating: with no selector there is nothing to pick. - // A plain error, not cmdutil.FlagErrorf: a flag error makes the root - // print the usage text to stdout, which would break the JSON contract. if opts.NonInteractive && opts.AppID == "" && opts.AppName == "" { return fmt.Errorf("--app-id or --app-name is required in non-interactive mode") } @@ -100,22 +100,6 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { return cmd } -// applyNonInteractive turns off every prompt and defaults the output to JSON, -// leaving an explicit --output untouched. -func applyNonInteractive(opts *SelectOptions) { - if !opts.NonInteractive { - return - } - - opts.IO.SetNeverPrompt(true) - opts.IO.SetProgressIndicatorEnabled(false) - - if opts.PrintFlags != nil && opts.PrintFlags.OutputFormat != nil && - !opts.PrintFlags.HasStructuredOutput() { - *opts.PrintFlags.OutputFormat = "json" - } -} - // printSelection emits the structured document once the flow is done. The // human-readable flow output has already been written to stderr by then. func printSelection(opts *SelectOptions, app *dashboard.Application) error { @@ -149,9 +133,10 @@ func Run(f *cmdutil.Factory) (*dashboard.Application, error) { } func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) { - // Drop progress narration so the command emits the JSON document only. + // Move the progress narration to stderr so stdout carries the JSON document + // only. if opts.PrintFlags.HasStructuredOutput() { - defer cmdutil.DiscardHumanOutput(opts.IO)() + defer cmdutil.RedirectHumanOutput(opts.IO)() } cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/application/selectapp/select_test.go b/pkg/cmd/application/selectapp/select_test.go index c78e1bbf..c76b6286 100644 --- a/pkg/cmd/application/selectapp/select_test.go +++ b/pkg/cmd/application/selectapp/select_test.go @@ -106,7 +106,7 @@ func Test_runSelectCmd_NonInteractiveWritesJSONOnlyToStdout(t *testing.T) { opts.NonInteractive = true opts.PrintFlags = cmdutil.NewPrintFlags() // Mirrors what the command does before running. - applyNonInteractive(opts) + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) stdout, stderr := captureOutput(t, opts.IO) @@ -114,8 +114,6 @@ func Test_runSelectCmd_NonInteractiveWritesJSONOnlyToStdout(t *testing.T) { require.NoError(t, err) require.NoError(t, printSelection(opts, app)) - // The JSON document is the whole output: progress narration is dropped, not - // moved to stderr, and key material never appears. var got apputil.ApplicationOutput require.NoError(t, json.Unmarshal(stdout.Bytes(), &got), "stdout: %q", stdout.String()) assert.Equal(t, apputil.ApplicationOutput{ @@ -125,43 +123,9 @@ func Test_runSelectCmd_NonInteractiveWritesJSONOnlyToStdout(t *testing.T) { }, got) assert.NotContains(t, stdout.String(), "API key") assert.NotContains(t, stdout.String(), "new-key") - assert.Empty(t, stderr.String()) -} - -func Test_applyNonInteractive_DefaultsToJSON(t *testing.T) { - io, _, _, _ := iostreams.Test() - opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags()} - - // Flag off: nothing changes. - applyNonInteractive(opts) - assert.False(t, opts.PrintFlags.HasStructuredOutput()) - assert.False(t, io.GetNeverPrompt()) - - opts.NonInteractive = true - applyNonInteractive(opts) - assert.True(t, io.GetNeverPrompt()) - assert.Equal(t, "json", *opts.PrintFlags.OutputFormat) -} - -// The spinner writes to stderr even on a TTY, so a non-interactive run has to -// silence it too. -func Test_applyNonInteractive_DisablesProgressIndicator(t *testing.T) { - io, _, _, _ := iostreams.Test() - io.SetProgressIndicatorEnabled(true) - opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} - - applyNonInteractive(opts) - - assert.False(t, io.GetProgressIndicatorEnabled()) -} - -func Test_applyNonInteractive_KeepsExplicitOutput(t *testing.T) { - io, _, _, _ := iostreams.Test() - opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} - *opts.PrintFlags.OutputFormat = "jsonpath={.id}" - applyNonInteractive(opts) - assert.Equal(t, "jsonpath={.id}", *opts.PrintFlags.OutputFormat) + assert.Contains(t, stderr.String(), "API key generated for application APP1") + assert.NotContains(t, stderr.String(), "new-key") } func TestNewSelectCmd_NonInteractiveRequiresSelector(t *testing.T) { @@ -175,7 +139,7 @@ func TestNewSelectCmd_NonInteractiveRequiresSelector(t *testing.T) { func Test_printSelection_NoApplication(t *testing.T) { io, _, _, _ := iostreams.Test() opts := &SelectOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} - applyNonInteractive(opts) + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) assert.ErrorContains(t, printSelection(opts, nil), "no applications found") } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 2c3d91f5..6f296616 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -101,7 +101,9 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { `), Args: validators.NoArgs(), RunE: func(cmd *cobra.Command, args []string) error { - applyNonInteractive(opts) + if opts.NonInteractive { + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + } return runLoginCmd(cmd.Context(), opts) }, } @@ -119,22 +121,6 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { return cmd } -// applyNonInteractive turns off every prompt and defaults the output to JSON, -// leaving an explicit --output untouched. -func applyNonInteractive(opts *LoginOptions) { - if !opts.NonInteractive { - return - } - - opts.IO.SetNeverPrompt(true) - opts.IO.SetProgressIndicatorEnabled(false) - - if opts.PrintFlags != nil && opts.PrintFlags.OutputFormat != nil && - !opts.PrintFlags.HasStructuredOutput() { - *opts.PrintFlags.OutputFormat = "json" - } -} - func runLoginCmd(ctx context.Context, opts *LoginOptions) error { return RunOAuthFlow(ctx, opts, false) } @@ -201,11 +187,12 @@ func runOAuthFlowSteps( signup bool, tracker *telemetry.FlowTracker, ) (*dashboard.Application, error) { - // Drop progress narration so the command emits the JSON document only. This - // includes the authorize URL, so --non-interactive relies on the browser - // opening: pair it with --no-browser only if the URL isn't needed. + // Move the progress narration to stderr so stdout carries the JSON document + // only. It stays visible, which matters most for the authorize URL: printed + // or not, the flow can't complete until that URL is opened, so a caller that + // can't open a browser needs to see it. if opts.PrintFlags.HasStructuredOutput() { - defer cmdutil.DiscardHumanOutput(opts.IO)() + defer cmdutil.RedirectHumanOutput(opts.IO)() } cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 2a1105b2..0ac36fc1 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -180,38 +180,12 @@ func TestTrackOAuthFlowOutcome(t *testing.T) { } } -func TestApplyNonInteractive(t *testing.T) { - io, _, _, _ := iostreams.Test() - opts := &LoginOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags()} - - // Flag off: nothing changes. - applyNonInteractive(opts) - assert.False(t, io.GetNeverPrompt()) - assert.False(t, opts.PrintFlags.HasStructuredOutput()) - - opts.NonInteractive = true - applyNonInteractive(opts) - assert.False(t, io.CanPrompt()) - assert.Equal(t, "json", *opts.PrintFlags.OutputFormat) -} - -func TestApplyNonInteractive_KeepsExplicitOutput(t *testing.T) { - io, _, _, _ := iostreams.Test() - opts := &LoginOptions{IO: io, PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true} - *opts.PrintFlags.OutputFormat = "jsonpath={.application.id}" - - applyNonInteractive(opts) - assert.Equal(t, "jsonpath={.application.id}", *opts.PrintFlags.OutputFormat) -} - -// applyNonInteractive must tolerate the shared LoginOptions of `auth signup`, -// which exposes no output flags. -func TestApplyNonInteractive_NilPrintFlags(t *testing.T) { +// `auth signup` shares LoginOptions but exposes no output flags. +func TestPrintLoginResult_NilPrintFlags(t *testing.T) { io, _, _, _ := iostreams.Test() opts := &LoginOptions{IO: io, NonInteractive: true} + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) - applyNonInteractive(opts) - assert.False(t, io.CanPrompt()) assert.NoError(t, printLoginResult(opts, &dashboard.Application{ID: "APP1"})) } @@ -233,7 +207,7 @@ func TestPrintLoginResult_JSONWithoutAPIKey(t *testing.T) { PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true, } - applyNonInteractive(opts) + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) app := &dashboard.Application{ ID: "APP1", @@ -275,7 +249,7 @@ func TestPrintLoginResult_NoApplication(t *testing.T) { PrintFlags: cmdutil.NewPrintFlags(), NonInteractive: true, } - applyNonInteractive(opts) + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) require.NoError(t, printLoginResult(opts, nil)) diff --git a/pkg/cmd/shared/apputil/output.go b/pkg/cmd/shared/apputil/output.go index dea05aec..2c021cbc 100644 --- a/pkg/cmd/shared/apputil/output.go +++ b/pkg/cmd/shared/apputil/output.go @@ -6,9 +6,8 @@ import ( ) // ApplicationOutput is the machine-readable view of an application, shared by -// the commands that select one. Field names match -// `algolia application current --output json`. API key material is -// deliberately left out: it must never reach stdout. +// every command that emits one (`application current`, `application select`, +// `auth login`) type ApplicationOutput struct { ID string `json:"id"` Alias string `json:"alias"` diff --git a/pkg/cmdutil/result_output.go b/pkg/cmdutil/result_output.go index 37146be4..285aaa53 100644 --- a/pkg/cmdutil/result_output.go +++ b/pkg/cmdutil/result_output.go @@ -2,7 +2,6 @@ package cmdutil import ( "fmt" - "io" "github.com/algolia/cli/pkg/iostreams" ) @@ -22,13 +21,26 @@ func PrintRunSummary( return err } -// DiscardHumanOutput drops the progress narration a command writes to stdout, -// so the only thing it emits is the structured document. Diagnostics written -// straight to stderr (warnings, errors) are untouched. The returned function -// restores the original writer and must run before the document is printed. -func DiscardHumanOutput(ios *iostreams.IOStreams) func() { +// ApplyNonInteractive turns off every prompt and defaults the output to JSON, +// leaving an explicit --output untouched. printFlags may be nil, for commands +// that expose no output flags. +func ApplyNonInteractive(ios *iostreams.IOStreams, printFlags *PrintFlags) { + ios.SetNeverPrompt(true) + ios.SetProgressIndicatorEnabled(false) + + if printFlags != nil && printFlags.OutputFormat != nil && !printFlags.HasStructuredOutput() { + *printFlags.OutputFormat = "json" + } +} + +// RedirectHumanOutput sends the progress narration a command writes to stdout +// to stderr instead, so stdout carries nothing but the structured document. +// +// The returned function restores the original writer and must run before the +// document is printed. +func RedirectHumanOutput(ios *iostreams.IOStreams) func() { original := ios.Out - ios.Out = io.Discard + ios.Out = ios.ErrOut return func() { ios.Out = original diff --git a/pkg/cmdutil/result_output_test.go b/pkg/cmdutil/result_output_test.go new file mode 100644 index 00000000..8c3440b7 --- /dev/null +++ b/pkg/cmdutil/result_output_test.go @@ -0,0 +1,65 @@ +package cmdutil + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/algolia/cli/pkg/iostreams" +) + +func Test_ApplyNonInteractive_DefaultsToJSON(t *testing.T) { + io, _, _, _ := iostreams.Test() + printFlags := NewPrintFlags() + + ApplyNonInteractive(io, printFlags) + + assert.True(t, io.GetNeverPrompt()) + assert.False(t, io.CanPrompt()) + assert.Equal(t, "json", *printFlags.OutputFormat) +} + +func Test_ApplyNonInteractive_KeepsExplicitOutput(t *testing.T) { + io, _, _, _ := iostreams.Test() + printFlags := NewPrintFlags() + *printFlags.OutputFormat = "jsonpath={.id}" + + ApplyNonInteractive(io, printFlags) + + assert.Equal(t, "jsonpath={.id}", *printFlags.OutputFormat) +} + +// The spinner writes to stderr even on a TTY, so a non-interactive run has to +// silence it too. +func Test_ApplyNonInteractive_DisablesProgressIndicator(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetProgressIndicatorEnabled(true) + + ApplyNonInteractive(io, NewPrintFlags()) + + assert.False(t, io.GetProgressIndicatorEnabled()) +} + +// Commands with no output flags (e.g. `auth signup`) pass a nil PrintFlags. +func Test_ApplyNonInteractive_NilPrintFlags(t *testing.T) { + io, _, _, _ := iostreams.Test() + + ApplyNonInteractive(io, nil) + + assert.False(t, io.CanPrompt()) +} + +// The narration has to survive somewhere: `auth login` prints the authorize URL +// through it, and nothing else can complete the flow. +func Test_RedirectHumanOutput(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + restore := RedirectHumanOutput(io) + fmt.Fprintln(io.Out, "Waiting for authentication...") + restore() + fmt.Fprintln(io.Out, `{"success":true}`) + + assert.Equal(t, "{\"success\":true}\n", stdout.String()) + assert.Equal(t, "Waiting for authentication...\n", stderr.String()) +}