From 8d20aec6ca24c024dda6870869858473db71bd07 Mon Sep 17 00:00:00 2001 From: JC Sackett Date: Mon, 8 Nov 2021 11:23:56 -0500 Subject: [PATCH] feat: adds prompt for overwriting when scaffold is rerun Currently running `scaffold init` a second time results in an error message being propogated to the user as the cli output. This is harmless, as the error regards the files being created already existing, but not a great UX. This adds promptui to run an interactive prompt for overwriting the files. If the user selects to overwite, the command behaves as though it were run with the `-force` option. If the user doesn't, it ends the command but doesn't return an error. Other related changes introduced by this change: * The cli app is wrapped in a new VervetApp struct, allowing config options specific to vervet but not to the CLI interface to be set-- one of these options is a prompting interface. * The vervet app is added to the context passed into the cli app, so that vervet specific info can be used in the commands called by the cli app. Fixes #56 --- cmd/cmd.go | 44 ++++++++++++++++++ cmd/compiler_test.go | 6 +-- cmd/scaffold.go | 24 +++++++++- cmd/scaffold_test.go | 86 +++++++++++++++++++++++++++++++++-- cmd/version_test.go | 6 +-- cmd/vervet/main.go | 2 +- go.mod | 1 + go.sum | 10 ++++ internal/scaffold/scaffold.go | 16 +++++++ 9 files changed, 183 insertions(+), 12 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index eb83d72e..ac2541d7 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,12 +2,16 @@ package cmd import ( + "context" + "errors" "fmt" "io" "os" "path/filepath" + "strings" "time" + "github.com/manifoldco/promptui" "github.com/urfave/cli/v2" ) @@ -16,6 +20,7 @@ type VervetParams struct { Stdin io.ReadCloser Stdout io.WriteCloser Stderr io.WriteCloser + Prompt VervetPrompt } // VervetApp contains the cli Application. @@ -24,6 +29,21 @@ type VervetApp struct { Params VervetParams } +// VervetPrompt defines the interface for interactive prompts in vervet. +type VervetPrompt interface { + Confirm(label string) (bool, error) +} + +type runKey string + +var vervetKey = runKey("vervet") + +// Run runs the cli.App with the Vervet config params. +func (v *VervetApp) Run(args []string) error { + context := context.WithValue(context.Background(), vervetKey, v) + return v.App.RunContext(context, args) +} + // NewApp returns a new VervetApp with the provided params. func NewApp(vp VervetParams) *VervetApp { return &VervetApp{ @@ -149,11 +169,35 @@ func NewApp(vp VervetParams) *VervetApp { } } +// Prompt is the default interactive prompt for vervet. +type Prompt struct{} + +// Confirm implements VervetPrompt.Confirm +func (p Prompt) Confirm(label string) (bool, error) { + prompt := promptui.Prompt{ + Label: fmt.Sprintf("%v (y/N)?", label), + Default: "N", + Validate: func(input string) error { + input = strings.ToLower(input) + if input != "n" && input != "y" { + return errors.New("you must pick y or n") + } + return nil + }, + } + result, err := prompt.Run() + if err != nil { + return false, err + } + return (result == "y"), nil +} + // Vervet is the vervet application with the CLI application. var Vervet = NewApp(VervetParams{ Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr, + Prompt: Prompt{}, }) func absPath(path string) (string, error) { diff --git a/cmd/compiler_test.go b/cmd/compiler_test.go index 3e0785a6..03af111f 100644 --- a/cmd/compiler_test.go +++ b/cmd/compiler_test.go @@ -16,7 +16,7 @@ import ( func TestCompile(t *testing.T) { c := qt.New(t) dstDir := c.TempDir() - err := cmd.Vervet.App.Run([]string{"vervet", "compile", testdata.Path("resources"), dstDir}) + err := cmd.Vervet.Run([]string{"vervet", "compile", testdata.Path("resources"), dstDir}) c.Assert(err, qt.IsNil) tests := []struct { version string @@ -49,7 +49,7 @@ func TestCompile(t *testing.T) { func TestCompileInclude(t *testing.T) { c := qt.New(t) dstDir := c.TempDir() - err := cmd.Vervet.App.Run([]string{"vervet", "compile", "-I", testdata.Path("resources/include.yaml"), testdata.Path("resources"), dstDir}) + err := cmd.Vervet.Run([]string{"vervet", "compile", "-I", testdata.Path("resources/include.yaml"), testdata.Path("resources"), dstDir}) c.Assert(err, qt.IsNil) tests := []struct { @@ -87,6 +87,6 @@ func TestCompileInclude(t *testing.T) { func TestCompileConflict(t *testing.T) { c := qt.New(t) dstDir := c.TempDir() - err := cmd.Vervet.App.Run([]string{"vervet", "compile", "../testdata/conflict", dstDir}) + err := cmd.Vervet.Run([]string{"vervet", "compile", "../testdata/conflict", dstDir}) c.Assert(err, qt.ErrorMatches, `failed to load spec versions: conflict: .*`) } diff --git a/cmd/scaffold.go b/cmd/scaffold.go index 3b9bcd56..e44f5c82 100644 --- a/cmd/scaffold.go +++ b/cmd/scaffold.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "path/filepath" @@ -33,7 +34,28 @@ func ScaffoldInit(ctx *cli.Context) error { return err } err = sc.Organize() - if err != nil { + if err == scaffold.ErrAlreadyInitialized { + // If the project files already exist, prompt the user to see if they want to overwrite them. + // TODO: replace using Context.Value directly with a helper func to be used in other commands. + vervetApp, ok := ctx.Context.Value(vervetKey).(*VervetApp) + if !ok { + return errors.New("could not retrieve vervet app from context") + } + prompt := vervetApp.Params.Prompt + overwrite, err := prompt.Confirm("Scaffold already initialized; do you want to overwrite") + if err != nil { + return err + } + if overwrite { + forceFn := scaffold.Force(true) + forceFn(sc) + // If an error happens with --force enabled, something new has gone wrong. + if err = sc.Organize(); err != nil { + return err + } + } + return nil + } else if err != nil { return err } err = sc.Init() diff --git a/cmd/scaffold_test.go b/cmd/scaffold_test.go index f6a8a3c5..d791c8ae 100644 --- a/cmd/scaffold_test.go +++ b/cmd/scaffold_test.go @@ -3,6 +3,7 @@ package cmd_test import ( "io/ioutil" "os" + "strings" "testing" qt "github.com/frankban/quicktest" @@ -11,15 +12,92 @@ import ( "github.com/snyk/vervet/testdata" ) +var vervetConfigFile = "./.vervet.yaml" + +type testPrompt struct { + ReturnVal bool +} + +func (tp *testPrompt) Confirm(label string) (bool, error) { + return tp.ReturnVal, nil +} + +var filemark = "bad wolf" + +// markFile adds a string to a file so we can check if that file is being overwritten. +func markTestFile(filename string) error { + // Write a string to the file; we should see this string removed when + f, err := os.OpenFile(filename, os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + if err != nil { + return err + } + _, err = f.Write([]byte(filemark)) + return err +} + +// markInFile checks if the filemark is present, determining if the file has been +// overwritten. +func markInFile(filename string) (bool, error) { + content, err := ioutil.ReadFile(vervetConfigFile) + if err != nil { + return false, err + } + return strings.Contains(string(content), "bad wolf"), nil +} + func TestScaffold(t *testing.T) { c := qt.New(t) dstDir := c.TempDir() cd(c, dstDir) - // Create an API project from a scaffold - err := cmd.Vervet.App.Run([]string{"vervet", "scaffold", "init", testdata.Path("test-scaffold")}) + + prompt := testPrompt{} + testApp := cmd.NewApp(cmd.VervetParams{ + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + Prompt: &prompt, + }) + + // Running init creates the project files. + err := testApp.Run([]string{"vervet", "scaffold", "init", testdata.Path("test-scaffold")}) + c.Assert(err, qt.IsNil) + + // Rerunning init asks the user if they want to overwrite; if they say no + // the command ends... + prompt.ReturnVal = false + err = markTestFile(vervetConfigFile) + c.Assert(err, qt.IsNil) + err = testApp.Run([]string{"vervet", "scaffold", "init", testdata.Path("test-scaffold")}) + c.Assert(err, qt.IsNil) + fileMarked, err := markInFile(vervetConfigFile) + c.Assert(err, qt.IsNil) + c.Assert(fileMarked, qt.IsTrue) + + // ...if the user selects yes, it will overwrite the project files. + prompt.ReturnVal = true + err = testApp.Run([]string{"vervet", "scaffold", "init", testdata.Path("test-scaffold")}) + c.Assert(err, qt.IsNil) + fileMarked, err = markInFile(vervetConfigFile) + c.Assert(err, qt.IsNil) + c.Assert(fileMarked, qt.IsFalse) + + // Rerunning init with the force option overwrites the project files. + prompt.ReturnVal = false + err = markTestFile(vervetConfigFile) c.Assert(err, qt.IsNil) - // Generate a new resource version in the project - err = cmd.Vervet.App.Run([]string{"vervet", "version", "new", "--version", "2021-10-01", "v3", "foo"}) + err = testApp.Run([]string{"vervet", "scaffold", "init", "--force", testdata.Path("test-scaffold")}) + c.Assert(err, qt.IsNil) + fileMarked, err = markInFile(vervetConfigFile) + c.Assert(err, qt.IsNil) + c.Assert(fileMarked, qt.IsFalse) + + // A new resource version can be generated in the project after initialization has completed. + err = testApp.Run([]string{"vervet", "version", "new", "--version", "2021-10-01", "v3", "foo"}) c.Assert(err, qt.IsNil) for _, item := range []string{".vervet/templates/README.tmpl", ".vervet.yaml", ".vervet/extras/foo", ".vervet/extras/bar/bar"} { _, err = os.Stat(item) diff --git a/cmd/version_test.go b/cmd/version_test.go index c1651341..dea62668 100644 --- a/cmd/version_test.go +++ b/cmd/version_test.go @@ -41,7 +41,7 @@ func TestVersionFiles(t *testing.T) { defer output.Close() c.Patch(&os.Stdout, output) cd(c, testdata.Path(".")) - err = cmd.Vervet.App.Run([]string{"vervet", "version", "files"}) + err = cmd.Vervet.Run([]string{"vervet", "version", "files"}) c.Assert(err, qt.IsNil) }) out, err := ioutil.ReadFile(tmpFile) @@ -64,7 +64,7 @@ func TestVersionList(t *testing.T) { defer output.Close() c.Patch(&os.Stdout, output) cd(c, testdata.Path(".")) - err = cmd.Vervet.App.Run([]string{"vervet", "version", "list"}) + err = cmd.Vervet.Run([]string{"vervet", "version", "list"}) c.Assert(err, qt.IsNil) }) out, err := ioutil.ReadFile(tmpFile) @@ -95,7 +95,7 @@ func TestVersionNew(t *testing.T) { copyToDir(c, testdata.Path(".vervet/resource/version/index.ts.tmpl"), versionTemplateDir) copyToDir(c, testdata.Path(".vervet/resource/version/spec.yaml.tmpl"), versionTemplateDir) cd(c, projectDir) - err := cmd.Vervet.App.Run([]string{"vervet", "version", "new", "testdata", "foo"}) + err := cmd.Vervet.Run([]string{"vervet", "version", "new", "testdata", "foo"}) c.Assert(err, qt.IsNil) versions, err := vervet.LoadResourceVersions(filepath.Join(projectDir, "generated", "foo")) c.Assert(err, qt.IsNil) diff --git a/cmd/vervet/main.go b/cmd/vervet/main.go index a32cfeb1..9b6869e6 100644 --- a/cmd/vervet/main.go +++ b/cmd/vervet/main.go @@ -8,7 +8,7 @@ import ( ) func main() { - err := cmd.Vervet.App.Run(os.Args) + err := cmd.Vervet.Run(os.Args) if err != nil { log.Fatal(err) } diff --git a/go.mod b/go.mod index e268dd94..63dc7e1f 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/go-cmp v0.5.5 github.com/google/uuid v1.3.0 github.com/mailru/easyjson v0.7.7 // indirect + github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mitchellh/reflectwalk v1.0.2 github.com/olekukonko/tablewriter v0.0.5 diff --git a/go.sum b/go.sum index 92f7814e..8d67518d 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/bmatcuk/doublestar/v4 v4.0.2 h1:X0krlUVAVmtr2cRoTqR8aDMrDqnB36ht8wpWTiQ3jsA= github.com/bmatcuk/doublestar/v4 v4.0.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -38,6 +44,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -63,6 +71,8 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 6a7c1dfd..206e26cd 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -12,6 +12,9 @@ import ( "github.com/ghodss/yaml" ) +// ErrAlreadyInitialized is used when scaffolding is being run on a project that is already setup. +var ErrAlreadyInitialized = fmt.Errorf("project files already exist") + // Scaffold defines a Vervet API project scaffold. type Scaffold struct { dst, src string @@ -85,6 +88,19 @@ func New(dst, src string, options ...Option) (*Scaffold, error) { func (s *Scaffold) Organize() error { for dstItem, srcItem := range s.manifest.Organize { dstPath := filepath.Join(s.dst, dstItem) + // If we're not force overwriting, check if files already exist. + if !s.force { + _, err := os.Stat(dstPath) + if err == nil { + // Project files already exist. + return ErrAlreadyInitialized + } + if !os.IsNotExist(err) { + // Something else went wrong; the file not existing is the desired + // state. + return err + } + } srcPath := filepath.Join(s.src, srcItem) err := s.copyItem(dstPath, srcPath) if err != nil {