diff --git a/pkg/exec/command.go b/pkg/exec/command.go new file mode 100644 index 00000000..fdcd4216 --- /dev/null +++ b/pkg/exec/command.go @@ -0,0 +1,79 @@ +package exec + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" +) + +var ErrInvalidArgs = errors.New("invalid command args") + +var _ Command = (*execCmd)(nil) + +type Command interface { + // Run executes the command and returns stdout as string as well as an error if the command failed. + // The error is of type ErrWithStderr and can be type asserted to that to get the exact stderr output. + Run() (string, error) + // Args returns all the command's args including the executable name as the first item. + Args() []string +} + +type execCmd struct { + *exec.Cmd +} + +func (ec *execCmd) Run() (string, error) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + ec.Stdout, ec.Stderr = stdout, stderr + + if err := ec.Cmd.Run(); err != nil { + return "", &ErrWithStderr{ + Wrapped: err, + Args: ec.Cmd.Args, + StdErr: stderr.Bytes(), + } + } + + return stdout.String(), nil +} + +func (ec *execCmd) Args() []string { + return ec.Cmd.Args +} + +func NewExecCmd(args []string, opts ...NewExecCmdOption) Command { + cmd := exec.Command(args[0], args[1:]...) + for _, opt := range opts { + opt(cmd) + } + + return &execCmd{Cmd: cmd} +} + +type NewExecCmdOption func(*exec.Cmd) + +func WithTargetDir(targetDir string) NewExecCmdOption { + return func(c *exec.Cmd) { + c.Dir = targetDir + } +} + +type ErrWithStderr struct { + Wrapped error + StdErr []byte + Args []string +} + +func (e *ErrWithStderr) Error() string { + if len(e.StdErr) > 0 { + return fmt.Sprintf("failed running `%s`, %q: %s", strings.Join(e.Args, " "), e.StdErr, e.Wrapped.Error()) + } + + return fmt.Sprintf("failed running `%s`, make sure %s is available: %s", strings.Join(e.Args, " "), e.Args[0], e.Wrapped.Error()) +} + +func (e *ErrWithStderr) Unwrap() error { + return e.Wrapped +} diff --git a/pkg/exec/command_group.go b/pkg/exec/command_group.go new file mode 100644 index 00000000..995b63fb --- /dev/null +++ b/pkg/exec/command_group.go @@ -0,0 +1,43 @@ +package exec + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// CommandGroup contains commands that are run one after another. +// As soon as one command fails the whole CommandGroup will be stopped and all other +// not yet executed commands are skipped. +type CommandGroup struct { + // func run before any of the commands is executed + PreRun func() error + // Commands to run + Commands []Command +} + +func (cg *CommandGroup) Run() error { + if len(cg.Commands) == 0 { + return nil + } + + if cg.PreRun != nil { + if err := cg.PreRun(); err != nil { + var skipsCmds []string + for _, cmd := range cg.Commands { + skipsCmds = append(skipsCmds, fmt.Sprintf("`%s`", strings.Join(cmd.Args(), " "))) + } + + return errors.Wrapf(err, "skipping %s", strings.Join(skipsCmds, ", ")) + } + } + + for _, cmd := range cg.Commands { + if _, err := cmd.Run(); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/gocli/version.go b/pkg/gocli/version.go new file mode 100644 index 00000000..7da1e4ae --- /dev/null +++ b/pkg/gocli/version.go @@ -0,0 +1,37 @@ +package gocli + +import ( + "bytes" + "os/exec" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" +) + +var ErrMalformedGoVersionOutput = errors.New("malformed go version output") + +func Semver() (*semver.Version, error) { + stdout := &bytes.Buffer{} + + goVersion := exec.Command("go", "version") + goVersion.Stdout = stdout + + err := goVersion.Run() + if err != nil { + return nil, errors.Wrap(err, "failed checking go version") + } + + versionParts := strings.Split(stdout.String(), " ") + if len(versionParts) != 4 { + return nil, errors.Wrap(ErrMalformedGoVersionOutput, stdout.String()) + } + + goSemverString := strings.TrimPrefix(versionParts[2], "go") + goSemver, err := semver.NewVersion(goSemverString) + if err != nil { + return nil, errors.Wrap(ErrMalformedGoVersionOutput, stdout.String()) + } + + return goSemver, nil +} diff --git a/pkg/gocli/version_test.go b/pkg/gocli/version_test.go new file mode 100644 index 00000000..05425961 --- /dev/null +++ b/pkg/gocli/version_test.go @@ -0,0 +1,17 @@ +package gocli_test + +import ( + "runtime" + "strings" + "testing" + + "github.com/schwarzit/go-template/pkg/gocli" + "github.com/stretchr/testify/require" +) + +func Test_Semver(t *testing.T) { + version, err := gocli.Semver() + require.NoError(t, err) + // check that the version this test was build with matches the go version provided by goexec.Semver. + require.Equal(t, strings.TrimPrefix(runtime.Version(), "go"), version.String()) +} diff --git a/pkg/gotemplate/new.go b/pkg/gotemplate/new.go index ae1dddfa..8c36c346 100644 --- a/pkg/gotemplate/new.go +++ b/pkg/gotemplate/new.go @@ -5,24 +5,30 @@ import ( "fmt" "io/fs" "os" - "os/exec" "path" "reflect" "strconv" "strings" "text/template" + "github.com/Masterminds/semver/v3" "github.com/pkg/errors" "gopkg.in/yaml.v3" gotemplate "github.com/schwarzit/go-template" + ownexec "github.com/schwarzit/go-template/pkg/exec" + "github.com/schwarzit/go-template/pkg/gocli" ) +const minGoVersion = "1.15" + var ( ErrAlreadyExists = errors.New("already exists") ErrParameterNotSet = errors.New("parameter not set") ErrMalformedInput = errors.New("malformed input") ErrParameterSet = errors.New("parameter set but has no effect in this context") + + minGoVersionSemver = semver.MustParse(minGoVersion) ) type ErrTypeMismatch struct { @@ -224,32 +230,51 @@ func (gt *GT) InitNewProject(opts *NewRepositoryOptions) (err error) { } gt.printProgressf("Initializing git and Go modules...") - if err := initRepo(targetDir, opts.OptionValues.Base["moduleName"].(string)); err != nil { - return err - } + gt.initRepo(targetDir, opts.OptionValues.Base["moduleName"].(string)) return nil } -func initRepo(targetDir, moduleName string) error { - gitInit := exec.Command("git", "init") - gitInit.Dir = targetDir +func (gt *GT) initRepo(targetDir, moduleName string) { + commandGroups := []ownexec.CommandGroup{ + { + Commands: []ownexec.Command{ + ownexec.NewExecCmd([]string{"git", "init"}, ownexec.WithTargetDir(targetDir)), + }, + }, + { + PreRun: checkGoVersion, + Commands: []ownexec.Command{ + ownexec.NewExecCmd([]string{"go", "mod", "init", moduleName}, ownexec.WithTargetDir(targetDir)), + ownexec.NewExecCmd([]string{"go", "mod", "tidy"}, ownexec.WithTargetDir(targetDir)), + }, + }, + } - if err := gitInit.Run(); err != nil { - return err + failedCGs := 0 + for _, cg := range commandGroups { + if err := cg.Run(); err != nil { + gt.printWarningf(err.Error()) + failedCGs++ + } } - goModInit := exec.Command("go", "mod", "init", moduleName) - goModInit.Dir = targetDir + if failedCGs > 0 { + gt.printWarningf("one or more initialization steps failed, pls see warnings for more info.") + } +} - if err := goModInit.Run(); err != nil { +func checkGoVersion() error { + goSemver, err := gocli.Semver() + if err != nil { return err } - goModTidy := exec.Command("go", "mod", "tidy") - goModTidy.Dir = targetDir + if goSemver.LessThan(minGoVersionSemver) { + return fmt.Errorf("go version %s is not supported, requires at least %s", goSemver.String(), minGoVersionSemver.String()) + } - return goModTidy.Run() + return nil } func postHook(options *Options, optionValues *OptionValues, targetDir string) error {