feat: Add confirm prompts to some cli actions (#1591)

* feat: Add confirm prompts to some cli actions
- Add optional -y skip. Standardize -y flag across commands
This commit is contained in:
Steven Masley
2022-05-20 10:59:04 -05:00
committed by GitHub
parent 4f70f84635
commit ad946c3902
10 changed files with 121 additions and 39 deletions

View File

@ -24,8 +24,21 @@ type PromptOptions struct {
Validate func(string) error Validate func(string) error
} }
func AllowSkipPrompt(cmd *cobra.Command) {
cmd.Flags().BoolP("yes", "y", false, "Bypass prompts")
}
// Prompt asks the user for input. // Prompt asks the user for input.
func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) { 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+" ") _, _ = fmt.Fprint(cmd.OutOrStdout(), Styles.FocusedPrompt.String()+opts.Text+" ")
if opts.IsConfirm { if opts.IsConfirm {
opts.Default = "yes" opts.Default = "yes"

View File

@ -1,7 +1,9 @@
package cliui_test package cliui_test
import ( import (
"bytes"
"context" "context"
"io"
"os" "os"
"os/exec" "os/exec"
"testing" "testing"
@ -24,7 +26,7 @@ func TestPrompt(t *testing.T) {
go func() { go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{ resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example", Text: "Example",
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
msgChan <- resp msgChan <- resp
}() }()
@ -41,7 +43,7 @@ func TestPrompt(t *testing.T) {
resp, err := newPrompt(ptty, cliui.PromptOptions{ resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example", Text: "Example",
IsConfirm: true, IsConfirm: true,
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
doneChan <- resp doneChan <- resp
}() }()
@ -50,6 +52,47 @@ func TestPrompt(t *testing.T) {
require.Equal(t, "yes", <-doneChan) 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.Run("JSON", func(t *testing.T) {
t.Parallel() t.Parallel()
ptty := ptytest.New(t) ptty := ptytest.New(t)
@ -57,7 +100,7 @@ func TestPrompt(t *testing.T) {
go func() { go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{ resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example", Text: "Example",
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
doneChan <- resp doneChan <- resp
}() }()
@ -73,7 +116,7 @@ func TestPrompt(t *testing.T) {
go func() { go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{ resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example", Text: "Example",
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
doneChan <- resp doneChan <- resp
}() }()
@ -89,7 +132,7 @@ func TestPrompt(t *testing.T) {
go func() { go func() {
resp, err := newPrompt(ptty, cliui.PromptOptions{ resp, err := newPrompt(ptty, cliui.PromptOptions{
Text: "Example", Text: "Example",
}) }, nil)
require.NoError(t, err) require.NoError(t, err)
doneChan <- resp 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 := "" value := ""
cmd := &cobra.Command{ cmd := &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@ -110,7 +153,12 @@ func newPrompt(ptty *ptytest.PTY, opts cliui.PromptOptions) (string, error) {
return err 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()) cmd.SetIn(ptty.Input())
return value, cmd.ExecuteContext(context.Background()) return value, cmd.ExecuteContext(context.Background())
} }

View File

@ -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(), &templateName, "template", "t", "CODER_TEMPLATE_NAME", "", "Specify a template name.")
cliflag.StringVarP(cmd.Flags(), &parameterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.") cliflag.StringVarP(cmd.Flags(), &parameterFile, "parameter-file", "", "CODER_PARAMETER_FILE", "", "Specify a file path with parameter values.")
return cmd return cmd

View File

@ -1,9 +1,11 @@
package cli_test package cli_test
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -46,34 +48,24 @@ func TestCreate(t *testing.T) {
<-doneChan <-doneChan
}) })
t.Run("CreateFromList", func(t *testing.T) { t.Run("CreateFromListWithSkip", func(t *testing.T) {
t.Parallel() t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client) user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, 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) clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{}) cmdCtx, done := context.WithTimeout(context.Background(), time.Second*3)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
go func() { go func() {
defer close(doneChan) defer done()
err := cmd.Execute() err := cmd.ExecuteContext(cmdCtx)
require.NoError(t, err) require.NoError(t, err)
}() }()
matches := []string{ // No pty interaction needed since we use the -y skip prompt flag
"Confirm create", "yes", <-cmdCtx.Done()
} require.ErrorIs(t, cmdCtx.Err(), context.Canceled)
for i := 0; i < len(matches); i += 2 {
match := matches[i]
value := matches[i+1]
pty.ExpectMatch(match)
pty.WriteLine(value)
}
<-doneChan
}) })
t.Run("FromNothing", func(t *testing.T) { t.Run("FromNothing", func(t *testing.T) {

View File

@ -11,13 +11,21 @@ import (
// nolint // nolint
func delete() *cobra.Command { func delete() *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Annotations: workspaceCommand, Annotations: workspaceCommand,
Use: "delete <workspace>", Use: "delete <workspace>",
Short: "Delete a workspace", Short: "Delete a workspace",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { 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) client, err := createClient(cmd)
if err != nil { if err != nil {
return err return err
@ -40,4 +48,6 @@ func delete() *cobra.Command {
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
}, },
} }
cliui.AllowSkipPrompt(cmd)
return cmd
} }

View File

@ -20,7 +20,7 @@ func TestDelete(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.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) clitest.SetupConfig(t, client, root)
doneChan := make(chan struct{}) doneChan := make(chan struct{})
pty := ptytest.New(t) pty := ptytest.New(t)

View File

@ -10,12 +10,20 @@ import (
) )
func start() *cobra.Command { func start() *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Annotations: workspaceCommand, Annotations: workspaceCommand,
Use: "start <workspace>", Use: "start <workspace>",
Short: "Build a workspace with the start state", Short: "Build a workspace with the start state",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { 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) client, err := createClient(cmd)
if err != nil { if err != nil {
return err return err
@ -38,4 +46,6 @@ func start() *cobra.Command {
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
}, },
} }
cliui.AllowSkipPrompt(cmd)
return cmd
} }

View File

@ -10,12 +10,20 @@ import (
) )
func stop() *cobra.Command { func stop() *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Annotations: workspaceCommand, Annotations: workspaceCommand,
Use: "stop <workspace>", Use: "stop <workspace>",
Short: "Build a workspace with the stop state", Short: "Build a workspace with the stop state",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { 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) client, err := createClient(cmd)
if err != nil { if err != nil {
return err return err
@ -38,4 +46,6 @@ func stop() *cobra.Command {
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before) return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
}, },
} }
cliui.AllowSkipPrompt(cmd)
return cmd
} }

View File

@ -21,7 +21,6 @@ import (
func templateCreate() *cobra.Command { func templateCreate() *cobra.Command {
var ( var (
yes bool
directory string directory string
provisioner string provisioner string
parameterFile string parameterFile string
@ -85,14 +84,12 @@ func templateCreate() *cobra.Command {
return err return err
} }
if !yes { _, err = cliui.Prompt(cmd, cliui.PromptOptions{
_, err = cliui.Prompt(cmd, cliui.PromptOptions{ Text: "Confirm create?",
Text: "Confirm create?", IsConfirm: true,
IsConfirm: true, })
}) if err != nil {
if err != nil { return err
return err
}
} }
_, err = client.CreateTemplate(cmd.Context(), organization.ID, codersdk.CreateTemplateRequest{ _, err = client.CreateTemplate(cmd.Context(), organization.ID, codersdk.CreateTemplateRequest{
@ -123,7 +120,7 @@ func templateCreate() *cobra.Command {
if err != nil { if err != nil {
panic(err) panic(err)
} }
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Bypass prompts") cliui.AllowSkipPrompt(cmd)
return cmd return cmd
} }

View File

@ -108,6 +108,7 @@ func templateUpdate() *cobra.Command {
currentDirectory, _ := os.Getwd() currentDirectory, _ := os.Getwd()
cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from") cmd.Flags().StringVarP(&directory, "directory", "d", currentDirectory, "Specify the directory to create from")
cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend") cmd.Flags().StringVarP(&provisioner, "test.provisioner", "", "terraform", "Customize the provisioner backend")
cliui.AllowSkipPrompt(cmd)
// This is for testing! // This is for testing!
err := cmd.Flags().MarkHidden("test.provisioner") err := cmd.Flags().MarkHidden("test.provisioner")
if err != nil { if err != nil {