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 34661c5c..0cb65956 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 + if opts.NonInteractive { + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + } + + // Fail before authenticating: with no selector there is nothing to pick. + 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,29 @@ 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 } +// 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 +133,12 @@ func Run(f *cmdutil.Factory) (*dashboard.Application, error) { } func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) { + // Move the progress narration to stderr so stdout carries the JSON document + // only. + if opts.PrintFlags.HasStructuredOutput() { + defer cmdutil.RedirectHumanOutput(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..c76b6286 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,65 @@ 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. + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + + stdout, stderr := captureOutput(t, opts.IO) + + app, err := runSelectCmd(opts) + require.NoError(t, err) + require.NoError(t, printSelection(opts, app)) + + 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.Contains(t, stderr.String(), "API key generated for application APP1") + assert.NotContains(t, stderr.String(), "new-key") +} + +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} + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + + 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..6f296616 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,15 @@ 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 { + if opts.NonInteractive { + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + } return runLoginCmd(cmd.Context(), opts) }, } @@ -84,6 +114,9 @@ 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 } @@ -102,9 +135,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 +186,40 @@ func runOAuthFlowSteps( opts *LoginOptions, signup bool, tracker *telemetry.FlowTracker, -) error { +) (*dashboard.Application, error) { + // 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.RedirectHumanOutput(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 +233,7 @@ func runOAuthFlowSteps( if appName == "" && opts.IO.CanPrompt() { appName, err = apputil.PromptName() if err != nil { - return err + return nil, err } } @@ -169,14 +241,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 +258,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 +269,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 +310,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..0ac36fc1 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,83 @@ func TestTrackOAuthFlowOutcome(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) + + 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, + } + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + + 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, + } + cmdutil.ApplyNonInteractive(opts.IO, opts.PrintFlags) + + 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..2c021cbc --- /dev/null +++ b/pkg/cmd/shared/apputil/output.go @@ -0,0 +1,31 @@ +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 +// every command that emits one (`application current`, `application select`, +// `auth login`) +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..285aaa53 100644 --- a/pkg/cmdutil/result_output.go +++ b/pkg/cmdutil/result_output.go @@ -20,3 +20,29 @@ func PrintRunSummary( _, err := fmt.Fprintln(ios.Out, human) return err } + +// 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 = 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()) +} 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("") }