diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index ac39404e27d3f..16f0c438f4007 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -24,8 +24,21 @@ type PromptOptions struct { Validate func(string) error } +func AllowSkipPrompt(cmd *cobra.Command) { + cmd.Flags().BoolP("yes", "y", false, "Bypass prompts") +} + // Prompt asks the user for input. func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { + // If the cmd has a "yes" flag for skipping confirm prompts, honor it. + // If it's not a "Confirm" prompt, then don't skip. As the default value of + // "yes" makes no sense. + if opts.IsConfirm && cmd.Flags().Lookup("yes") != nil { + if skip, _ := cmd.Flags().GetBool("yes"); skip { + return "yes", nil + } + } + _, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ") if opts.IsConfirm { opts.Default = "yes" diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 1926349c2d1fc..9c9167ad09708 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -1,7 +1,9 @@ package cliui_test import ( + "bytes" "context" + "io" "os" "os/exec" "testing" @@ -24,7 +26,7 @@ func TestPrompt(t *testing.T) { go func() { resp, err := newPrompt(ptty, cliui.PromptOptions{ Text: "Example", - }) + }, nil) require.NoError(t, err) msgChan <- resp }() @@ -41,7 +43,7 @@ func TestPrompt(t *testing.T) { resp, err := newPrompt(ptty, cliui.PromptOptions{ Text: "Example", IsConfirm: true, - }) + }, nil) require.NoError(t, err) doneChan <- resp }() @@ -50,6 +52,47 @@ func TestPrompt(t *testing.T) { require.Equal(t, "yes", <-doneChan) }) + t.Run("Skip", func(t *testing.T) { + t.Parallel() + ptty := ptytest.New(t) + var buf bytes.Buffer + + // Copy all data written out to a buffer. When we close the ptty, we can + // no longer read from the ptty.Output(), but we can read what was + // written to the buffer. + dataRead, doneReading := context.WithTimeout(context.Background(), time.Second*2) + go func() { + // This will throw an error sometimes. The underlying ptty + // has its own cleanup routines in t.Cleanup. Instead of + // trying to control the close perfectly, just let the ptty + // double close. This error isn't important, we just + // want to know the ptty is done sending output. + _, _ = io.Copy(&buf, ptty.Output()) + doneReading() + }() + + doneChan := make(chan string) + go func() { + resp, err := newPrompt(ptty, cliui.PromptOptions{ + Text: "ShouldNotSeeThis", + IsConfirm: true, + }, func(cmd *cobra.Command) { + cliui.AllowSkipPrompt(cmd) + cmd.SetArgs([]string{"-y"}) + }) + require.NoError(t, err) + doneChan <- resp + }() + + require.Equal(t, "yes", <-doneChan) + // Close the reader to end the io.Copy + require.NoError(t, ptty.Close(), "close eof reader") + // Wait for the IO copy to finish + <-dataRead.Done() + // Timeout error means the output was hanging + require.ErrorIs(t, dataRead.Err(), context.Canceled, "should be canceled") + require.Len(t, buf.Bytes(), 0, "expect no output") + }) t.Run("JSON", func(t *testing.T) { t.Parallel() ptty := ptytest.New(t) @@ -57,7 +100,7 @@ func TestPrompt(t *testing.T) { go func() { resp, err := newPrompt(ptty, cliui.PromptOptions{ Text: "Example", - }) + }, nil) require.NoError(t, err) doneChan <- resp }() @@ -73,7 +116,7 @@ func TestPrompt(t *testing.T) { go func() { resp, err := newPrompt(ptty, cliui.PromptOptions{ Text: "Example", - }) + }, nil) require.NoError(t, err) doneChan <- resp }() @@ -89,7 +132,7 @@ func TestPrompt(t *testing.T) { go func() { resp, err := newPrompt(ptty, cliui.PromptOptions{ Text: "Example", - }) + }, nil) require.NoError(t, err) doneChan <- resp }() @@ -101,7 +144,7 @@ func TestPrompt(t *testing.T) { }) } -func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) { +func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions, cmdOpt func(cmd *cobra.Command)) (string, error) { value := "" cmd := &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { @@ -110,7 +153,12 @@ func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) { return err }, } - cmd.SetOutput(ptty.Output()) + // Optionally modify the cmd + if cmdOpt != nil { + cmdOpt(cmd) + } + cmd.SetOut(ptty.Output()) + cmd.SetErr(ptty.Output()) cmd.SetIn(ptty.Input()) return value, cmd.ExecuteContext(context.Background()) } diff --git a/cli/create.go b/cli/create.go index 4f09f71bc7eb3..1f762a55cca45 100644 --- a/cli/create.go +++ b/cli/create.go @@ -204,6 +204,7 @@ func create() *cobra.Command { }, } + cliui.AllowSkipPrompt(cmd) cliflag.StringVarP(cmd.Flags(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.") cliflag.StringVarP(cmd.Flags(), ¶meterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.") return cmd diff --git a/cli/create_test.go b/cli/create_test.go index 955a001fa1245..3e29e3bdabad7 100644 --- a/cli/create_test.go +++ b/cli/create_test.go @@ -1,9 +1,11 @@ package cli_test import ( + "context" "fmt" "os" "testing" + "time" "github.com/stretchr/testify/require" @@ -46,34 +48,24 @@ func TestCreate(t *testing.T) { <-doneChan }) - t.Run("CreateFromList", func(t *testing.T) { + t.Run("CreateFromListWithSkip", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - cmd, root := clitest.New(t, "create", "my-workspace") + cmd, root := clitest.New(t, "create", "my-workspace", "-y") clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) - pty := ptytest.New(t) - cmd.SetIn(pty.Input()) - cmd.SetOut(pty.Output()) + cmdCtx, done := context.WithTimeout(context.Background(), time.Second*3) go func() { - defer close(doneChan) - err := cmd.Execute() + defer done() + err := cmd.ExecuteContext(cmdCtx) require.NoError(t, err) }() - matches := []string{ - "Confirm create", "yes", - } - for i := 0; i < len(matches); i += 2 { - match := matches[i] - value := matches[i+1] - pty.ExpectMatch(match) - pty.WriteLine(value) - } - <-doneChan + // No pty interaction needed since we use the -y skip prompt flag + <-cmdCtx.Done() + require.ErrorIs(t, cmdCtx.Err(), context.Canceled) }) t.Run("FromNothing", func(t *testing.T) { diff --git a/cli/delete.go b/cli/delete.go index 8d1de59c2e653..42be08965fed4 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -11,13 +11,21 @@ import ( // nolint func delete() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "delete ", Short: "Delete a workspace", Aliases: []string{"rm"}, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + _, err := cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Confirm delete workspace?", + IsConfirm: true, + }) + if err != nil { + return err + } + client, err := createClient(cmd) if err != nil { return err @@ -40,4 +48,6 @@ func delete() *cobra.Command { return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) }, } + cliui.AllowSkipPrompt(cmd) + return cmd } diff --git a/cli/delete_test.go b/cli/delete_test.go index f9e1102a0eb39..e7af61b40cd82 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -20,7 +20,7 @@ func TestDelete(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) - cmd, root := clitest.New(t, "delete", workspace.Name) + cmd, root := clitest.New(t, "delete", workspace.Name, "-y") clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t) diff --git a/cli/start.go b/cli/start.go index 1c562ddd9d093..7e35b22aa915a 100644 --- a/cli/start.go +++ b/cli/start.go @@ -10,12 +10,20 @@ import ( ) func start() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "start ", Short: "Build a workspace with the start state", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + _, err := cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Confirm start workspace?", + IsConfirm: true, + }) + if err != nil { + return err + } + client, err := createClient(cmd) if err != nil { return err @@ -38,4 +46,6 @@ func start() *cobra.Command { return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) }, } + cliui.AllowSkipPrompt(cmd) + return cmd } diff --git a/cli/stop.go b/cli/stop.go index af1f44da58af0..f2455458bd1f7 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -10,12 +10,20 @@ import ( ) func stop() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "stop ", Short: "Build a workspace with the stop state", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + _, err := cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Confirm stop workspace?", + IsConfirm: true, + }) + if err != nil { + return err + } + client, err := createClient(cmd) if err != nil { return err @@ -38,4 +46,6 @@ func stop() *cobra.Command { return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) }, } + cliui.AllowSkipPrompt(cmd) + return cmd } diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 6c98afd66cfc8..06205aa008eb2 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -21,7 +21,6 @@ import ( func templateCreate() *cobra.Command { var ( - yes bool directory string provisioner string parameterFile string @@ -85,14 +84,12 @@ func templateCreate() *cobra.Command { return err } - if !yes { - _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: "Confirm create?", - IsConfirm: true, - }) - if err != nil { - return err - } + _, err = cliui.Prompt(cmd, cliui.PromptOptions{ + Text: "Confirm create?", + IsConfirm: true, + }) + if err != nil { + return err } _, err = client.CreateTemplate(cmd.Context(), organization.ID, codersdk.CreateTemplateRequest{ @@ -123,7 +120,7 @@ func templateCreate() *cobra.Command { if err != nil { panic(err) } - cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Bypass prompts") + cliui.AllowSkipPrompt(cmd) return cmd } diff --git a/cli/templateupdate.go b/cli/templateupdate.go index 70c2611cea687..32902aab3b518 100644 --- a/cli/templateupdate.go +++ b/cli/templateupdate.go @@ -108,6 +108,7 @@ func templateUpdate() *cobra.Command { currentDirectory, _ := os.Getwd() cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from") cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend") + cliui.AllowSkipPrompt(cmd) // This is for testing! err := cmd.Flags().MarkHidden("test.provisioner") if err != nil {