diff --git a/.examples/cli/scaffold.yaml b/.examples/cli/scaffold.yaml index 95a521e..9676738 100644 --- a/.examples/cli/scaffold.yaml +++ b/.examples/cli/scaffold.yaml @@ -24,3 +24,9 @@ questions: - "green" - "blue" - "yellow" + +presets: + default: + Project: "scaffold-test-default" + description: "This is a test description" + colors: ["red", "green"] diff --git a/.examples/cli/{{ .ProjectKebab }}/main.go b/.examples/cli/{{ .ProjectKebab }}/main.go index d180297..ff481ed 100644 --- a/.examples/cli/{{ .ProjectKebab }}/main.go +++ b/.examples/cli/{{ .ProjectKebab }}/main.go @@ -2,91 +2,8 @@ package main import ( "fmt" - "os" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/urfave/cli/v2" -) - -var ( - // Build information. Populated at build-time via -ldflags flag. - version = "dev" - commit = "HEAD" - date = "now" ) -func build() string { - short := commit - if len(commit) > 7 { - short = commit[:7] - } - - return fmt.Sprintf("%s (%s) %s", version, short, date) -} - func main() { - ctrl := &controller{} - - app := &cli.App{ - Name: "{{ .Project }}", - Usage: "{{ .Scaffold.description }}", - Version: build(), - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "cwd", - Usage: "current working directory", - Value: ".", - }, - &cli.StringFlag{ - Name: "log-level", - Usage: "log level (debug, info, warn, error, fatal, panic)", - Value: "panic", - }, - }, - Before: func(ctx *cli.Context) error { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - - ctrl.cwd = ctx.String("cwd") - ctrl.logLevel = ctx.String("log-level") - - switch ctrl.logLevel { - case "debug": - log.Level(zerolog.DebugLevel) - case "info": - log.Level(zerolog.InfoLevel) - case "warn": - log.Level(zerolog.WarnLevel) - case "error": - log.Level(zerolog.ErrorLevel) - case "fatal": - log.Level(zerolog.FatalLevel) - default: - log.Level(zerolog.PanicLevel) - } - - return nil - }, - Commands: []*cli.Command{ - { - Name: "hello", - Usage: "Says hello world", - Action: ctrl.HelloWorld, - }, - }, - } - - if err := app.Run(os.Args); err != nil { - log.Fatal().Err(err).Msg("failed to run scaffold") - } -} - -type controller struct { - cwd string - logLevel string -} - -func (c *controller) HelloWorld(ctx *cli.Context) error { - fmt.Println("Hello, your favorite colors are {{ .Scaffold.colors | join `, ` }}") - return nil + fmt.Println("colors={{ .Scaffold.colors | join `, ` }} description={{ .Scaffold.description }}") } diff --git a/.examples/nested/scaffold.yaml b/.examples/nested/scaffold.yaml new file mode 100644 index 0000000..37d4735 --- /dev/null +++ b/.examples/nested/scaffold.yaml @@ -0,0 +1,25 @@ +questions: + - name: "question_1" + prompt: + message: "Question 1" + required: true + - name: "question_2" + prompt: + message: "Question 2" + required: true + - name: "question_3" + prompt: + message: "Question 3" + required: true + - name: "question_4" + prompt: + message: "Question 4" + required: true + +presets: + default: + Project: "nested-defaults" + question_1: "Answer 1" + question_2: "Answer 2" + question_3: "Answer 3" + question_4: "Answer 4" diff --git a/.examples/nested/{{ .ProjectKebab }}/child/child_1.txt b/.examples/nested/{{ .ProjectKebab }}/child/child_1.txt new file mode 100644 index 0000000..da4c6e0 --- /dev/null +++ b/.examples/nested/{{ .ProjectKebab }}/child/child_1.txt @@ -0,0 +1 @@ +{{ .Scaffold.question_2 }} \ No newline at end of file diff --git a/.examples/nested/{{ .ProjectKebab }}/child/subchild/child_2.txt b/.examples/nested/{{ .ProjectKebab }}/child/subchild/child_2.txt new file mode 100644 index 0000000..c19d755 --- /dev/null +++ b/.examples/nested/{{ .ProjectKebab }}/child/subchild/child_2.txt @@ -0,0 +1 @@ +{{ .Scaffold.question_3 }} \ No newline at end of file diff --git a/.examples/nested/{{ .ProjectKebab }}/child/subchild/subsubchild/child_3.txt b/.examples/nested/{{ .ProjectKebab }}/child/subchild/subsubchild/child_3.txt new file mode 100644 index 0000000..f6994af --- /dev/null +++ b/.examples/nested/{{ .ProjectKebab }}/child/subchild/subsubchild/child_3.txt @@ -0,0 +1 @@ +{{ .Scaffold.question_4 }} \ No newline at end of file diff --git a/.examples/nested/{{ .ProjectKebab }}/root.txt b/.examples/nested/{{ .ProjectKebab }}/root.txt new file mode 100644 index 0000000..8c01375 --- /dev/null +++ b/.examples/nested/{{ .ProjectKebab }}/root.txt @@ -0,0 +1 @@ +{{ .Scaffold.question_1 }} \ No newline at end of file diff --git a/.examples/role/scaffold.yaml b/.examples/role/scaffold.yaml index e337e45..a2cd299 100644 --- a/.examples/role/scaffold.yaml +++ b/.examples/role/scaffold.yaml @@ -23,10 +23,8 @@ rewrites: computed: snaked: "{{ snakecase .Scaffold.role_name }}" static: false -inject: - - name: "add role to site.yaml" - path: site.yaml - at: "# $Scaffold.role_name" - template: | - - name: {{ .Scaffold.role_name }} - role: {{ .Computed.snaked }} + +test: + role_name: "test_role" + toggle: true + description: "This is a test role" diff --git a/.github/workflows/partial-tests.yml b/.github/workflows/partial-tests.yml index 206e67a..d8df88a 100644 --- a/.github/workflows/partial-tests.yml +++ b/.github/workflows/partial-tests.yml @@ -7,7 +7,7 @@ jobs: Go: runs-on: ubuntu-latest steps: - - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 @@ -26,3 +26,6 @@ jobs: - name: Test run: go test ./... -race + + - name: Test 'cli' example + run: ./tests/runner.sh diff --git a/app/commands/init.go b/app/commands/cmd_init.go similarity index 100% rename from app/commands/init.go rename to app/commands/cmd_init.go diff --git a/app/commands/lint.go b/app/commands/cmd_lint.go similarity index 100% rename from app/commands/lint.go rename to app/commands/cmd_lint.go diff --git a/app/commands/list.go b/app/commands/cmd_list.go similarity index 100% rename from app/commands/list.go rename to app/commands/cmd_list.go diff --git a/app/commands/cmd_new.go b/app/commands/cmd_new.go new file mode 100644 index 0000000..f35553c --- /dev/null +++ b/app/commands/cmd_new.go @@ -0,0 +1,168 @@ +package commands + +import ( + "fmt" + "math/rand" + "os" + "strings" + + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/hay-kot/scaffold/app/core/fsast" + "github.com/hay-kot/scaffold/app/scaffold" + "github.com/hay-kot/scaffold/app/scaffold/pkgs" + "github.com/sahilm/fuzzy" +) + +type FlagsNew struct { + NoPrompt bool + Preset string + Snapshot string +} + +func (ctrl *Controller) New(args []string, flags FlagsNew) error { + if len(args) == 0 { + return fmt.Errorf("missing scaffold name") + } + + path, err := ctrl.resolve(args[0], flags.NoPrompt) + if err != nil { + return err + } + + if path == "" { + return fmt.Errorf("missing scaffold path") + } + + rest := args[1:] + argvars, err := parseArgVars(rest) + if err != nil { + return err + } + + var varfunc func(*scaffold.Project) (map[string]any, error) + switch { + case flags.NoPrompt: + varfunc = func(p *scaffold.Project) (map[string]any, error) { + caseVars, ok := p.Conf.Presets[flags.Preset] + if !ok { + return nil, fmt.Errorf("case %s not found", flags.Preset) + } + + project, ok := caseVars["Project"].(string) + if !ok || project == "" { + // Generate 4 random digits + name := fmt.Sprintf("scaffold-test-%04d", rand.Intn(10000)) + caseVars["Project"] = name + project = name + } + p.Name = project + + // Test cases do not use rc.Defaults + vars := scaffold.MergeMaps(caseVars, argvars) + return vars, nil + } + + default: + varfunc = func(p *scaffold.Project) (map[string]any, error) { + vars := scaffold.MergeMaps(argvars, ctrl.rc.Defaults) + vars, err = p.AskQuestions(vars, ctrl.engine) + if err != nil { + return nil, err + } + + return vars, nil + } + } + + outfs := ctrl.Flags.OutputFS() + + err = ctrl.runscaffold(runconf{ + scaffolddir: path, + showMessages: !flags.NoPrompt, + varfunc: varfunc, + outputfs: outfs, + }) + if err != nil { + return err + } + + if flags.Snapshot != "" { + ast, err := fsast.New(outfs) + if err != nil { + return err + } + + if flags.Snapshot == "stdout" { + fmt.Println(ast.String()) + } else { + file, err := os.Create(flags.Snapshot) + if err != nil { + return err + } + + _ = file.Close() + + _, err = file.WriteString(ast.String()) + if err != nil { + return err + } + } + } + + return nil +} + +func (ctrl *Controller) fuzzyFallBack(str string) ([]string, []string, error) { + systemScaffolds, err := pkgs.ListSystem(os.DirFS(ctrl.Flags.Cache)) + if err != nil { + return nil, nil, err + } + + localScaffolds, err := pkgs.ListLocal(os.DirFS(ctrl.Flags.OutputDir)) + if err != nil { + return nil, nil, err + } + + systemMatches := fuzzy.Find(str, systemScaffolds) + systemMatchesOutput := make([]string, len(systemMatches)) + for i, match := range systemMatches { + systemMatchesOutput[i] = match.Str + } + + localMatches := fuzzy.Find(str, localScaffolds) + localMatchesOutput := make([]string, len(localMatches)) + for i, match := range localMatches { + localMatchesOutput[i] = match.Str + } + + return systemMatchesOutput, localMatchesOutput, nil +} + +func basicAuthAuthorizer(pkgurl, username, password string) pkgs.AuthProviderFunc { + return func(url string) (transport.AuthMethod, bool) { + if url != pkgurl { + return nil, false + } + + return &http.BasicAuth{ + Username: username, + Password: password, + }, true + } +} + +func parseArgVars(args []string) (map[string]any, error) { + vars := make(map[string]any, len(args)) + + for _, v := range args { + if !strings.Contains(v, "=") { + return nil, fmt.Errorf("variable %s is not in the form of key=value", v) + } + + kv := strings.Split(v, "=") + vars[kv[0]] = kv[1] + } + + return vars, nil +} diff --git a/app/commands/controller.go b/app/commands/controller.go index bf6e3e4..e110e0f 100644 --- a/app/commands/controller.go +++ b/app/commands/controller.go @@ -3,6 +3,7 @@ package commands import ( "github.com/hay-kot/scaffold/app/core/engine" + "github.com/hay-kot/scaffold/app/core/rwfs" "github.com/hay-kot/scaffold/app/scaffold" ) @@ -13,16 +14,24 @@ type Flags struct { Cache string OutputDir string ScaffoldDirs []string - Cwd string +} + +// OutputFS returns a WriteFS based on the OutputDir flag +func (f Flags) OutputFS() rwfs.WriteFS { + if f.OutputDir == ":memory:" { + return rwfs.NewMemoryWFS() + } + + return rwfs.NewOsWFS(f.OutputDir) } type Controller struct { - // Global Flags + // Flags contains the CLI flags + // that are from the root command Flags Flags engine *engine.Engine rc *scaffold.ScaffoldRC - vars map[string]string prepared bool } diff --git a/app/commands/new.go b/app/commands/new.go deleted file mode 100644 index dfc7c63..0000000 --- a/app/commands/new.go +++ /dev/null @@ -1,269 +0,0 @@ -package commands - -import ( - "errors" - "fmt" - "os" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/lipgloss" - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/hay-kot/scaffold/app/core/rwfs" - "github.com/hay-kot/scaffold/app/scaffold" - "github.com/hay-kot/scaffold/app/scaffold/pkgs" - "github.com/rs/zerolog/log" - "github.com/sahilm/fuzzy" - "github.com/urfave/cli/v2" -) - -func (ctrl *Controller) fuzzyFallBack(str string) ([]string, []string, error) { - systemScaffolds, err := pkgs.ListSystem(os.DirFS(ctrl.Flags.Cache)) - if err != nil { - return nil, nil, err - } - - localScaffolds, err := pkgs.ListLocal(os.DirFS(ctrl.Flags.OutputDir)) - if err != nil { - return nil, nil, err - } - - systemMatches := fuzzy.Find(str, systemScaffolds) - systemMatchesOutput := make([]string, len(systemMatches)) - for i, match := range systemMatches { - systemMatchesOutput[i] = match.Str - } - - localMatches := fuzzy.Find(str, localScaffolds) - localMatchesOutput := make([]string, len(localMatches)) - for i, match := range localMatches { - localMatchesOutput[i] = match.Str - } - - return systemMatchesOutput, localMatchesOutput, nil -} - -var ( - bold = lipgloss.NewStyle().Bold(true) - colorRed = lipgloss.NewStyle().Foreground(lipgloss.Color("#dc2626")) -) - -func didYouMeanPrompt(given, suggestion string) bool { - bldr := strings.Builder{} - - // Couldn't find a scaffold named: - // 'foo' - // - // Did you mean: - // 'bar'? - // - // [y/n]: - - bldr.WriteString("\n ") - bldr.WriteString(bold.Render(colorRed.Render("could not find a scaffold named"))) - bldr.WriteString("\n ") - bldr.WriteString(given) - bldr.WriteString("\n\n") - bldr.WriteString(" ") - bldr.WriteString(bold.Render("did you mean")) - bldr.WriteString("\n ") - bldr.WriteString(suggestion) - bldr.WriteString("?\n\n ") - bldr.WriteString("[y/n]: ") - - out := bldr.String() - - var resp string - - fmt.Print(out) - fmt.Scanln(&resp) - - return resp == "y" -} - -func basicAuthAuthorizer(pkgurl, username, password string) pkgs.AuthProviderFunc { - return func(url string) (transport.AuthMethod, bool) { - if url != pkgurl { - return nil, false - } - - return &http.BasicAuth{ - Username: username, - Password: password, - }, true - } -} - -func (ctrl *Controller) Project(ctx *cli.Context) error { - argPath := ctx.Args().First() - if argPath == "" { - return fmt.Errorf("path is required") - } - - // Status() call for go-git is too slow to be used here - // https://github.com/go-git/go-git/issues/181 - if !ctrl.Flags.Force { - ok := checkWorkingTree(ctrl.Flags.OutputDir) - if !ok { - log.Warn().Msg("working tree is dirty, use --force to apply changes") - return nil - } - } - - resolver := pkgs.NewResolver(ctrl.rc.Shorts, ctrl.Flags.Cache, ".") - - if v, ok := ctrl.rc.Aliases[argPath]; ok { - argPath = v - } - - path, err := resolver.Resolve(argPath, ctrl.Flags.ScaffoldDirs, ctrl.rc) - if err != nil { - orgErr := err - - switch { - case errors.Is(err, transport.ErrAuthenticationRequired): - username, password, err := httpAuthPrompt() - if err != nil { - return err - } - - path, err = resolver.Resolve(argPath, ctrl.Flags.ScaffoldDirs, basicAuthAuthorizer(argPath, username, password)) - if err != nil { - return err - } - default: - systemMatches, localMatches, err := ctrl.fuzzyFallBack(argPath) - if err != nil { - return err - } - - var first string - var isSystemMatch bool - if len(systemMatches) > 0 { - first = systemMatches[0] - isSystemMatch = true - } - - if len(localMatches) > 0 { - first = localMatches[0] - } - - if first != "" { - useMatch := didYouMeanPrompt(argPath, first) - - if useMatch { - if isSystemMatch { - // prepend https:// so it resolves to the correct path - first = "https://" + first - } - - resolved, err := resolver.Resolve(first, ctrl.Flags.ScaffoldDirs, ctrl.rc) - if err != nil { - return err - } - - path = resolved - } - } - } - - if path == "" { - return fmt.Errorf("failed to resolve path: %w", orgErr) - } - } - - rest := ctx.Args().Tail() - - ctrl.vars = make(map[string]string, len(rest)) - for _, v := range rest { - kv := strings.Split(v, "=") - ctrl.vars[kv[0]] = kv[1] - } - - pfs := os.DirFS(path) - - p, err := scaffold.LoadProject(pfs, scaffold.Options{ - NoClobber: ctrl.Flags.NoClobber, - }) - if err != nil { - return err - } - - defaults := scaffold.MergeMaps(ctrl.vars, ctrl.rc.Defaults) - - if p.Conf.Messages.Pre != "" { - out, err := glamour.RenderWithEnvironmentConfig(p.Conf.Messages.Pre) - if err != nil { - return err - } - - fmt.Println(out) - } - - vars, err := p.AskQuestions(defaults, ctrl.engine) - if err != nil { - return err - } - - args := &scaffold.RWFSArgs{ - Project: p, - ReadFS: pfs, - WriteFS: rwfs.NewOsWFS(ctrl.Flags.OutputDir), - } - - vars, err = scaffold.BuildVars(ctrl.engine, args, vars) - if err != nil { - return err - } - - err = scaffold.RenderRWFS(ctrl.engine, args, vars) - if err != nil { - return err - } - - if p.Conf.Messages.Post != "" { - rendered, err := ctrl.engine.TmplString(p.Conf.Messages.Post, vars) - if err != nil { - return err - } - - out, err := glamour.RenderWithEnvironmentConfig(rendered) - if err != nil { - return err - } - - fmt.Println(out) - } - - return nil -} - -func httpAuthPrompt() (username string, password string, err error) { - qs := []*survey.Question{ - { - Name: "username", - Prompt: &survey.Input{Message: "Username:"}, - Validate: survey.Required, - }, - { - Name: "password", - Prompt: &survey.Password{ - Message: "Password/Access Token:", - }, - }, - } - - answers := struct { - Username string - Password string - }{} - - err = survey.Ask(qs, &answers) - if err != nil { - return "", "", fmt.Errorf("failed to parse http auth input: %w", err) - } - - return answers.Username, answers.Password, nil -} diff --git a/app/commands/prompts.go b/app/commands/prompts.go new file mode 100644 index 0000000..ed2dd25 --- /dev/null +++ b/app/commands/prompts.go @@ -0,0 +1,75 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/charmbracelet/lipgloss" +) + +var ( + bold = lipgloss.NewStyle().Bold(true) + colorRed = lipgloss.NewStyle().Foreground(lipgloss.Color("#dc2626")) +) + +func httpAuthPrompt() (username string, password string, err error) { + qs := []*survey.Question{ + { + Name: "username", + Prompt: &survey.Input{Message: "Username:"}, + Validate: survey.Required, + }, + { + Name: "password", + Prompt: &survey.Password{ + Message: "Password/Access Token:", + }, + }, + } + + answers := struct { + Username string + Password string + }{} + + err = survey.Ask(qs, &answers) + if err != nil { + return "", "", fmt.Errorf("failed to parse http auth input: %w", err) + } + + return answers.Username, answers.Password, nil +} + +func didYouMeanPrompt(given, suggestion string) bool { + bldr := strings.Builder{} + + // Couldn't find a scaffold named: + // 'foo' + // + // Did you mean: + // 'bar'? + // + // [y/n]: + + bldr.WriteString("\n ") + bldr.WriteString(bold.Render(colorRed.Render("could not find a scaffold named"))) + bldr.WriteString("\n ") + bldr.WriteString(given) + bldr.WriteString("\n\n") + bldr.WriteString(" ") + bldr.WriteString(bold.Render("did you mean")) + bldr.WriteString("\n ") + bldr.WriteString(suggestion) + bldr.WriteString("?\n\n ") + bldr.WriteString("[y/n]: ") + + out := bldr.String() + + var resp string + + fmt.Print(out) + fmt.Scanln(&resp) + + return resp == "y" +} diff --git a/app/commands/resolve.go b/app/commands/resolve.go new file mode 100644 index 0000000..80b33a4 --- /dev/null +++ b/app/commands/resolve.go @@ -0,0 +1,98 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/hay-kot/scaffold/app/scaffold/pkgs" + "github.com/rs/zerolog/log" +) + +func (ctrl *Controller) resolve(argPath string, noPrompt bool) (string, error) { + if argPath == "" { + return "", fmt.Errorf("path is required") + } + + // Status() call for go-git is too slow to be used here + // https://github.com/go-git/go-git/issues/181 + if !ctrl.Flags.Force { + ok := checkWorkingTree(ctrl.Flags.OutputDir) + if !ok { + log.Warn().Msg("working tree is dirty, use --force to apply changes") + return "", nil + } + } + + resolver := pkgs.NewResolver(ctrl.rc.Shorts, ctrl.Flags.Cache, ".") + + if v, ok := ctrl.rc.Aliases[argPath]; ok { + argPath = v + } + + path, err := resolver.Resolve(argPath, ctrl.Flags.ScaffoldDirs, ctrl.rc) + if err != nil { + orgErr := err + + switch { + case errors.Is(err, transport.ErrAuthenticationRequired): + if noPrompt { + return "", err + } + + username, password, err := httpAuthPrompt() + if err != nil { + return "", err + } + + path, err = resolver.Resolve(argPath, ctrl.Flags.ScaffoldDirs, basicAuthAuthorizer(argPath, username, password)) + if err != nil { + return "", err + } + default: + if noPrompt { + return "", err + } + + systemMatches, localMatches, err := ctrl.fuzzyFallBack(argPath) + if err != nil { + return "", err + } + + var first string + var isSystemMatch bool + if len(systemMatches) > 0 { + first = systemMatches[0] + isSystemMatch = true + } + + if len(localMatches) > 0 { + first = localMatches[0] + } + + if first != "" { + useMatch := didYouMeanPrompt(argPath, first) + + if useMatch { + if isSystemMatch { + // prepend https:// so it resolves to the correct path + first = "https://" + first + } + + resolved, err := resolver.Resolve(first, ctrl.Flags.ScaffoldDirs, ctrl.rc) + if err != nil { + return "", err + } + + path = resolved + } + } + } + + if path == "" { + return "", fmt.Errorf("failed to resolve path: %w", orgErr) + } + } + + return path, nil +} diff --git a/app/commands/runner.go b/app/commands/runner.go new file mode 100644 index 0000000..b7309e3 --- /dev/null +++ b/app/commands/runner.go @@ -0,0 +1,81 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/charmbracelet/glamour" + "github.com/hay-kot/scaffold/app/core/rwfs" + "github.com/hay-kot/scaffold/app/scaffold" +) + +type runconf struct { + // os path to the scaffold directory. + scaffolddir string + // showMessages is a flag to show pre/post messages. + showMessages bool + // varfunc is a function that returns a map of variables that is provided + // to the template engine. + varfunc func(*scaffold.Project) (map[string]any, error) + // outputdir is the output directory or filesystem. + outputfs rwfs.WriteFS +} + +// runscaffold runs the scaffold. This method exists outside of the `new` receiver function +// so that we can allow the `test` and `new` commands to share as much of the same code +// as possible. +func (ctrl *Controller) runscaffold(cfg runconf) error { + pfs := os.DirFS(cfg.scaffolddir) + p, err := scaffold.LoadProject(pfs, scaffold.Options{ + NoClobber: ctrl.Flags.NoClobber, + }) + if err != nil { + return err + } + + if cfg.showMessages && p.Conf.Messages.Pre != "" { + out, err := glamour.RenderWithEnvironmentConfig(p.Conf.Messages.Pre) + if err != nil { + return err + } + + fmt.Println(out) + } + + vars, err := cfg.varfunc(p) + if err != nil { + return err + } + + args := &scaffold.RWFSArgs{ + Project: p, + ReadFS: pfs, + WriteFS: cfg.outputfs, + } + + vars, err = scaffold.BuildVars(ctrl.engine, args, vars) + if err != nil { + return err + } + + err = scaffold.RenderRWFS(ctrl.engine, args, vars) + if err != nil { + return err + } + + if cfg.showMessages && p.Conf.Messages.Post != "" { + rendered, err := ctrl.engine.TmplString(p.Conf.Messages.Post, vars) + if err != nil { + return err + } + + out, err := glamour.RenderWithEnvironmentConfig(rendered) + if err != nil { + return err + } + + fmt.Println(out) + } + + return nil +} diff --git a/app/commands/update.go b/app/commands/update_cmd.go similarity index 100% rename from app/commands/update.go rename to app/commands/update_cmd.go diff --git a/app/scaffold/snapshot_ast_test.go b/app/core/fsast/ast.go similarity index 81% rename from app/scaffold/snapshot_ast_test.go rename to app/core/fsast/ast.go index 52e55af..0409970 100644 --- a/app/scaffold/snapshot_ast_test.go +++ b/app/core/fsast/ast.go @@ -1,4 +1,5 @@ -package scaffold +// Package fsast provides a way to build an abstract syntax tree (AST) of a file system. +package fsast import ( "bytes" @@ -45,7 +46,16 @@ func (n *AstNode) String() string { return n.string(0) } -func buildNodeTree(subFs fs.FS, root *AstNode) error { +func New(subFs fs.FS) (*AstNode, error) { + root := &AstNode{ + NodeType: DirNodeType, + Path: "ROOT_NODE", + } + + return root, Build(subFs, root) +} + +func Build(subFs fs.FS, root *AstNode) error { files, err := fs.ReadDir(subFs, ".") if err != nil { return err @@ -60,7 +70,7 @@ func buildNodeTree(subFs fs.FS, root *AstNode) error { node.NodeType = DirNodeType subsubFS, _ := fs.Sub(subFs, file.Name()) - err = buildNodeTree(subsubFS, node) + err = Build(subsubFS, node) if err != nil { return err } diff --git a/app/core/rwfs/mem.go b/app/core/rwfs/mem.go index 06e6486..fa43a14 100644 --- a/app/core/rwfs/mem.go +++ b/app/core/rwfs/mem.go @@ -2,6 +2,7 @@ package rwfs import ( "io/fs" + "strings" "github.com/psanford/memfs" ) @@ -19,6 +20,20 @@ type MemoryWFS struct { *memfs.FS } +func (m *MemoryWFS) MkdirAll(path string, perm fs.FileMode) error { + if path == "/" { + // special case root dir always exists + return nil + } + + return m.FS.MkdirAll(path, perm) +} + +func (m *MemoryWFS) WriteFile(path string, data []byte, perm fs.FileMode) error { + path = strings.TrimPrefix(path, "/") + return m.FS.WriteFile(path, data, perm) +} + func NewMemoryWFS() *MemoryWFS { return &MemoryWFS{ FS: memfs.New(), diff --git a/app/core/rwfs/rwfs.go b/app/core/rwfs/rwfs.go index cb21906..c9bbbf3 100644 --- a/app/core/rwfs/rwfs.go +++ b/app/core/rwfs/rwfs.go @@ -3,7 +3,6 @@ package rwfs import ( - "io" "io/fs" ) @@ -11,13 +10,6 @@ import ( // a file system. It is a alias for fs.FS. type ReadFS = fs.FS -// WFile is a file that can be written to. It is a alias for fs.File. -// that also implements io.Writer. -type WFile interface { - fs.File - io.Writer -} - // WriteFS is a file system that can be used to read and write files. // It is a alias for fs.FS that also implements Mkdir, MkdirAll and Create. type WriteFS interface { diff --git a/app/scaffold/merge.go b/app/scaffold/merge.go index b4b5fd8..8ac65c3 100644 --- a/app/scaffold/merge.go +++ b/app/scaffold/merge.go @@ -11,3 +11,12 @@ func MergeMaps[T any](maps ...map[string]T) map[string]T { } return out } + +// CastMap casts a map[string]T to a man[string]any map. +func CastMap[T any](m map[string]T) map[string]any { + out := map[string]any{} + for k, v := range m { + out[k] = v + } + return out +} diff --git a/app/scaffold/project.go b/app/scaffold/project.go index ab398a0..1d0651a 100644 --- a/app/scaffold/project.go +++ b/app/scaffold/project.go @@ -4,6 +4,7 @@ package scaffold import ( "fmt" "io/fs" + "maps" "strconv" "github.com/AlecAivazis/survey/v2" @@ -94,7 +95,7 @@ func (p *Project) validate() (str string, err error) { return "", fmt.Errorf("{{ .Project }} directory does not exist") } -func (p *Project) AskQuestions(def map[string]string, e *engine.Engine) (map[string]any, error) { +func (p *Project) AskQuestions(def map[string]any, e *engine.Engine) (map[string]any, error) { projectMode := p.NameTemplate != "templates" if projectMode { @@ -116,15 +117,16 @@ func (p *Project) AskQuestions(def map[string]string, e *engine.Engine) (map[str p.Conf.Questions = append(pre, p.Conf.Questions...) case true: - p.Name = name + nameStr, ok := name.(string) + if !ok { + return nil, fmt.Errorf("Project name must be a string") + } + + p.Name = nameStr } } - vars := make(map[string]any) - // Copy default values - for k, v := range def { - vars[k] = v - } + vars := maps.Clone(def) for _, q := range p.Conf.Questions { if q.When != "" { diff --git a/app/scaffold/project_scaffold_file.go b/app/scaffold/project_scaffold_file.go index 27c8002..2b8b5a2 100644 --- a/app/scaffold/project_scaffold_file.go +++ b/app/scaffold/project_scaffold_file.go @@ -9,6 +9,20 @@ import ( "gopkg.in/yaml.v3" ) +type ProjectScaffoldFile struct { + Skip []string `yaml:"skip"` + Questions []Question `yaml:"questions"` + Rewrites []Rewrite `yaml:"rewrites"` + Computed map[string]string `yaml:"computed"` + Messages struct { + Pre string `yaml:"pre"` + Post string `yaml:"post"` + } `yaml:"messages"` + Inject []Injectable `yaml:"inject"` + Features []Feature `yaml:"features"` + Presets map[string]map[string]any `yaml:"presets"` +} + type Rewrite struct { From string `yaml:"from"` To string `yaml:"to"` @@ -26,19 +40,6 @@ type Feature struct { Globs []string `yaml:"globs"` } -type ProjectScaffoldFile struct { - Skip []string `yaml:"skip"` - Questions []Question `yaml:"questions"` - Rewrites []Rewrite `yaml:"rewrites"` - Computed map[string]string `yaml:"computed"` - Messages struct { - Pre string `yaml:"pre"` - Post string `yaml:"post"` - } `yaml:"messages"` - Inject []Injectable `yaml:"inject"` - Features []Feature `yaml:"features"` -} - func ReadScaffoldFile(reader io.Reader) (*ProjectScaffoldFile, error) { var out ProjectScaffoldFile diff --git a/app/scaffold/rc.go b/app/scaffold/rc.go index cdfb295..04f8a16 100644 --- a/app/scaffold/rc.go +++ b/app/scaffold/rc.go @@ -22,7 +22,7 @@ type ScaffoldRC struct { // // These are injected into the template as variables for // every scaffold. - Defaults map[string]string `yaml:"defaults"` + Defaults map[string]any `yaml:"defaults"` // Aliases define a alias for a repository. // or filepath. diff --git a/app/scaffold/snapshot_test.go b/app/scaffold/snapshot_test.go index a5ce5cb..f71f86b 100644 --- a/app/scaffold/snapshot_test.go +++ b/app/scaffold/snapshot_test.go @@ -6,6 +6,7 @@ import ( "github.com/bradleyjkemp/cupaloy/v2" "github.com/hay-kot/scaffold/app/core/engine" + "github.com/hay-kot/scaffold/app/core/fsast" "github.com/hay-kot/scaffold/app/core/rwfs" "github.com/stretchr/testify/require" ) @@ -68,8 +69,8 @@ func Test_RenderRWFileSystem(t *testing.T) { Project: tt.p, } - root := &AstNode{ - NodeType: DirNodeType, + root := &fsast.AstNode{ + NodeType: fsast.DirNodeType, Path: "ROOT_NODE", } @@ -79,7 +80,7 @@ func Test_RenderRWFileSystem(t *testing.T) { err = RenderRWFS(tEngine, args, vars) require.NoError(t, err) - err = buildNodeTree(memFS, root) + err = fsast.Build(memFS, root) require.NoError(t, err) snapshot.SnapshotT(t, root.String()) diff --git a/docs/docs/scaffold-file.md b/docs/docs/scaffold-file.md index 7da714e..a8af825 100644 --- a/docs/docs/scaffold-file.md +++ b/docs/docs/scaffold-file.md @@ -226,4 +226,20 @@ features: - value: "{{ .Scaffold.database }}" globs: - "**/core/database/**/*" -``` \ No newline at end of file +``` + +### Presets + +Presets are a way to define a set of default values for a scaffold. These can be overridden by the user when running the scaffold. + +```yaml +presets: + default: + description: "A description of the project" + license: "MIT" + use_github_actions: true + colors: ["red", "green"] +``` + +!!! tip "Presets and Testing" + Presets can be used in conjunction with the `new` command for testing purposes. See [Testing Scaffolds](./testing-scaffolds.md) for more information. \ No newline at end of file diff --git a/docs/docs/testing-scaffolds.md b/docs/docs/testing-scaffolds.md new file mode 100644 index 0000000..9bbe016 --- /dev/null +++ b/docs/docs/testing-scaffolds.md @@ -0,0 +1,40 @@ +--- +title: Testing Scaffolds +--- + +Scaffold does not have a test command or framework, *however* it does provide some tools that can be utilized to implement tests for your scaffolds. + +## Testing with ASTs + +Scaffold provides a way to output an AST of the scaffolded files. This can be used with a diffing tool to compare the ASTs of the scaffolded files with the expected ASTs to ensure that the scaffolded files are correct. + +```bash +# Command +scaffold \ + --log-level="error" \ # set log level to error to avoid noise + --output-dir=":memory:" \ # render scaffold in memory + new \ + --preset="default" \ # use scaffold preset + --no-prompt \ # disable interactive prompts + --snapshot="stdout" \ # write snapshot to stdout + + +# Output +scaffold-test-5781: (type=dir) + main.go: (type=file) + package main + + import ( + "fmt" + ) + + func main() { + fmt.Println("colors=red, green description=This is a test description") + } +``` + +## Testing with Outputs + +An alternative approach is to test out output scaffolds by running whatever output is generated by the scaffold. For example, we use this approach to test our scaffolds by generating a Go program and running it to ensure that it compiles and runs as expected. + +See [cli.test.sh](https://github.com/hay-kot/scaffold/blob/main/tests/cli.test.sh) for an example of how to test scaffolds using this approach. \ No newline at end of file diff --git a/main.go b/main.go index e797619..bb772e7 100644 --- a/main.go +++ b/main.go @@ -81,7 +81,7 @@ func main() { }, &cli.StringFlag{ Name: "output-dir", - Usage: "current working directory (where scaffold will be created)", + Usage: "scaffold output directory (use ':memory:' for in-memory filesystem)", Value: ".", EnvVars: []string{"SCAFFOLD_OUT"}, }, @@ -102,21 +102,13 @@ func main() { ScaffoldDirs: ctx.StringSlice("scaffold-dir"), } - switch ctx.String("log-level") { - case "debug": - log.Logger = log.Level(zerolog.DebugLevel) - case "info": - log.Logger = log.Level(zerolog.InfoLevel) - case "warn": - log.Logger = log.Level(zerolog.WarnLevel) - case "error": - log.Logger = log.Level(zerolog.ErrorLevel) - case "fatal": - log.Logger = log.Level(zerolog.FatalLevel) - default: - log.Logger = log.Level(zerolog.PanicLevel) + level, err := zerolog.ParseLevel(ctx.String("log-level")) + if err != nil { + return fmt.Errorf("failed to parse log level: %w", err) } + log.Logger = log.Level(level) + dir := filepath.Dir(ctrl.Flags.ScaffoldRCPath) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create scaffoldrc directory: %w", err) @@ -165,7 +157,30 @@ func main() { Name: "new", Usage: "create a new project from a scaffold", UsageText: "scaffold new [scaffold (url | path)] [flags]", - Action: ctrl.Project, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "no-prompt", + Usage: "disable interactive mode", + Value: false, + }, + &cli.StringFlag{ + Name: "preset", + Usage: "preset to use for the scaffold", + Value: "", + }, + &cli.StringFlag{ + Name: "snapshot", + Usage: "path or `stdout` to save the output ast", + Value: "", + }, + }, + Action: func(ctx *cli.Context) error { + return ctrl.New(ctx.Args().Slice(), commands.FlagsNew{ + NoPrompt: ctx.Bool("no-prompt"), + Preset: ctx.String("preset"), + Snapshot: ctx.String("snapshot"), + }) + }, }, { Name: "list", diff --git a/taskfile.yml b/taskfile.yml index 6464a0f..35b6079 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -28,6 +28,11 @@ tasks: cmds: - gotestsum --watch -- -v ./... + test:scripts: + desc: Runs all go tests for the scripts + cmds: + - ./tests/runner.sh + coverage: desc: Runs all go tests with -race flag and generates a coverage report cmds: @@ -61,18 +66,11 @@ tasks: desc: Runs the main.go program with the cli scaffold cmds: - rm -rf ./gen/* - - | - go run main.go \ - new cli \ - "project=TEST_PROJECT" \ - "description"=TEST_PROJECT" \ - "colors=red" + - go run main.go new cli + - go run ./gen/*/main.go do:role: desc: Runs the main.go program with the role scaffold cmds: - rm -rf ./gen/* - - | - go run main.go \ - new role \ - "Use Github Actions=true" + - go run main.go new role diff --git a/tests/assert.sh b/tests/assert.sh new file mode 100755 index 0000000..84cb042 --- /dev/null +++ b/tests/assert.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Function to compare output with expected output and print result +assert_output() { + local output="$1" + local expected_output="$2" + + if [ "$output" = "$expected_output" ]; then + echo "Test passed: output matches the expected string." + exit 0 + else + echo "Expected: $expected_output" + echo "Got: $output" + echo "Test failed: output does not match the expected string." + exit 1 + fi +} + +# Function to assert snapshots +assert_snapshot() { + local snapshot_name="$1" + local output="$2" + local snapshots_dir="tests/snapshots" + local snapshot_file="$snapshots_dir/$snapshot_name" + + # Create snapshots directory if it doesn't exist + mkdir -p "$snapshots_dir" + + # Check if snapshot file exists + if [ -f "$snapshot_file" ]; then + # Compare current output with snapshot + diff_result=$(diff -u "$snapshot_file" <(echo "$output")) + if [ $? -eq 0 ]; then + echo "Test passed: output matches the snapshot '$snapshot_name'." + exit 0 + else + echo "Test failed: output does not match the snapshot '$snapshot_name'. Diff:" + echo "$diff_result" + exit 1 + fi + else + # Snapshot does not exist, create it + echo "$output" > "$snapshot_file" + echo "Snapshot '$snapshot_name' created." + exit 0 + fi +} \ No newline at end of file diff --git a/tests/cli-snapshot.test.sh b/tests/cli-snapshot.test.sh new file mode 100755 index 0000000..cf3a63b --- /dev/null +++ b/tests/cli-snapshot.test.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Source the assert_snapshot function +source tests/assert.sh + +# Your script continues as before... +output=$(go run main.go --log-level="error" \ + new \ + --preset="default" \ + --no-prompt \ + --snapshot="stdout" \ + cli) + +# Call the function to assert the snapshot +assert_snapshot "cli.snapshot.txt" "$output" \ No newline at end of file diff --git a/tests/cli.test.sh b/tests/cli.test.sh new file mode 100755 index 0000000..0aa16ba --- /dev/null +++ b/tests/cli.test.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Source the assertions file +source tests/assert.sh + +go run main.go --log-level="error" \ + new \ + --preset="default" \ + --no-prompt \ + --snapshot="stdout" \ + cli + +# Run the command and store the output in a variable +output=$(go run ./gen/scaffold-test*/main.go hello) + +expected_output="colors=red, green description=This is a test description" + +# Call the function to compare output with expected output +assert_output "$output" "$expected_output" \ No newline at end of file diff --git a/tests/nested-snapshot.test.sh b/tests/nested-snapshot.test.sh new file mode 100755 index 0000000..bc304f9 --- /dev/null +++ b/tests/nested-snapshot.test.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Source the assert_snapshot function +source tests/assert.sh + +# Your script continues as before... +output=$(go run main.go --log-level="error" \ + new \ + --preset="default" \ + --no-prompt \ + --snapshot="stdout" \ + nested) + +# Call the function to assert the snapshot +assert_snapshot "nested.snapshot.txt" "$output" \ No newline at end of file diff --git a/tests/runner.sh b/tests/runner.sh new file mode 100755 index 0000000..bdb76fe --- /dev/null +++ b/tests/runner.sh @@ -0,0 +1,25 @@ +#!/bin/bash +export SCAFFOLD_NO_CLOBBER="true" +export SCAFFOLD_OUT="gen" +export SCAFFOLD_DIR=".scaffold,.examples" + +checkmark="✓" +crossmark="✗" + +echo "Running Script Tests" +# run each test script in the tests directory +for test_script in tests/*.test.sh; do + rm -rf ./gen + + # if exit code of script is 0, print checkmark, else print crossmark + # and the output indented by 4 spaces + output=$($test_script 2>&1) + + if [ $? -eq 0 ]; then + echo " $checkmark $test_script" + else + echo " $crossmark $test_script" + # Print each line of the output indented by 4 spaces + echo "$output" | sed 's/^/ /' + fi +done \ No newline at end of file diff --git a/tests/snapshots/cli.snapshot.txt b/tests/snapshots/cli.snapshot.txt new file mode 100644 index 0000000..6df2e56 --- /dev/null +++ b/tests/snapshots/cli.snapshot.txt @@ -0,0 +1,12 @@ +scaffold-test-default: (type=dir) + main.go: (type=file) + package main + + import ( + "fmt" + ) + + func main() { + fmt.Println("colors=red, green description=This is a test description") + } + diff --git a/tests/snapshots/nested.snapshot.txt b/tests/snapshots/nested.snapshot.txt new file mode 100644 index 0000000..8a78b7d --- /dev/null +++ b/tests/snapshots/nested.snapshot.txt @@ -0,0 +1,12 @@ +nested-defaults: (type=dir) + child: (type=dir) + child_1.txt: (type=file) + Answer 2 + subchild: (type=dir) + child_2.txt: (type=file) + Answer 3 + subsubchild: (type=dir) + child_3.txt: (type=file) + Answer 4 + root.txt: (type=file) + Answer 1