Skip to content

Commit

Permalink
feat: support post scaffold hooks (#167)
Browse files Browse the repository at this point in the history
Co-authored-by: izeau <izeau@users.noreply.github.com>
  • Loading branch information
hay-kot and izeau authored Jun 3, 2024
1 parent 18c2811 commit f5c09cd
Show file tree
Hide file tree
Showing 19 changed files with 367 additions and 40 deletions.
4 changes: 4 additions & 0 deletions .examples/hooks/hooks/post_scaffold
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env python3

with open("{{ .ProjectKebab }}/file.txt", "a") as f:
f.write("Hello\n")
3 changes: 3 additions & 0 deletions .examples/hooks/scaffold.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
presets:
default:
Project: "scaffold-test-default"
1 change: 1 addition & 0 deletions .examples/hooks/{{ .ProjectKebab }}/file.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hook says:
8 changes: 4 additions & 4 deletions app/commands/cmd_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ func (ctrl *Controller) New(args []string, flags FlagsNew) error {
outfs := ctrl.Flags.OutputFS()

err = ctrl.runscaffold(runconf{
scaffolddir: path,
showMessages: !flags.NoPrompt,
varfunc: varfunc,
outputfs: outfs,
scaffolddir: path,
noPrompt: flags.NoPrompt,
varfunc: varfunc,
outputfs: outfs,
})
if err != nil {
return err
Expand Down
114 changes: 107 additions & 7 deletions app/commands/runner.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
package commands

import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"

"github.com/charmbracelet/glamour"
"github.com/charmbracelet/huh"
"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
// noPrompt is a flag to show pre/post messages.
noPrompt 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)
Expand All @@ -25,15 +30,15 @@ type runconf struct {
// 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{
scaffoldFS := os.DirFS(cfg.scaffolddir)
p, err := scaffold.LoadProject(scaffoldFS, scaffold.Options{
NoClobber: ctrl.Flags.NoClobber,
})
if err != nil {
return err
}

if cfg.showMessages && p.Conf.Messages.Pre != "" {
if !cfg.noPrompt && p.Conf.Messages.Pre != "" {
out, err := glamour.RenderWithEnvironmentConfig(p.Conf.Messages.Pre)
if err != nil {
return err
Expand All @@ -49,7 +54,7 @@ func (ctrl *Controller) runscaffold(cfg runconf) error {

args := &scaffold.RWFSArgs{
Project: p,
ReadFS: pfs,
ReadFS: scaffoldFS,
WriteFS: cfg.outputfs,
}

Expand All @@ -63,7 +68,14 @@ func (ctrl *Controller) runscaffold(cfg runconf) error {
return err
}

if cfg.showMessages && p.Conf.Messages.Post != "" {
if ctrl.rc.Settings.RunHooks != scaffold.RunHooksNever {
err = ctrl.runHook(scaffoldFS, cfg.outputfs, scaffold.PostScaffoldScripts, vars, cfg.noPrompt)
if err != nil {
return err
}
}

if !cfg.noPrompt && p.Conf.Messages.Post != "" {
rendered, err := ctrl.engine.TmplString(p.Conf.Messages.Post, vars)
if err != nil {
return err
Expand All @@ -79,3 +91,91 @@ func (ctrl *Controller) runscaffold(cfg runconf) error {

return nil
}

func (ctrl *Controller) runHook(
rfs rwfs.ReadFS,
wfs rwfs.WriteFS,
hookPrefix string,
vars any,
noPrompt bool,
) error {
sources, err := fs.ReadDir(rfs, scaffold.HooksDir)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to open %q hook file: %w", hookPrefix, err)
}
return nil
}

// find first glob match
var hookContents []byte
for _, source := range sources {
if source.IsDir() {
continue
}

if strings.HasPrefix(source.Name(), hookPrefix) {
path := filepath.Join(scaffold.HooksDir, source.Name())

hookContents, err = fs.ReadFile(rfs, path)
if err != nil {
return err
}
}
}

if len(hookContents) == 0 {
return nil
}

rendered, err := ctrl.engine.TmplString(string(hookContents), vars)
if err != nil {
return err
}

if !shouldRunHooks(ctrl.rc.Settings.RunHooks, noPrompt, hookPrefix, rendered) {
return nil
}

err = wfs.RunHook(hookPrefix, []byte(rendered), nil)
if err != nil && !errors.Is(err, rwfs.ErrHooksNotSupported) {
return err
}

return nil
}

// shouldRunHooks will resolve the users RunHooks preference and either return the preference
// or prompt the user for their choice when the preference is RunHooksPrompt
func shouldRunHooks(runPreference scaffold.RunHooksOption, noPrompt bool, name string, rendered string) bool {
for {
switch runPreference {
case scaffold.RunHooksAlways:
return true
case scaffold.RunHooksNever:
return false
case scaffold.RunHooksPrompt:
if noPrompt {
return false
}

err := huh.Run(huh.NewSelect[scaffold.RunHooksOption]().
Title(fmt.Sprintf("scaffold defines a %s hook", name)).
Options(
huh.NewOption("run", scaffold.RunHooksAlways),
huh.NewOption("skip", scaffold.RunHooksNever),
huh.NewOption("review", scaffold.RunHooksPrompt)).
Value(&runPreference))
if err != nil {
fmt.Fprint(os.Stderr, err)
return false
}

if runPreference == scaffold.RunHooksPrompt {
fmt.Printf("\n%s\n", rendered)
}
default:
return false
}
}
}
4 changes: 4 additions & 0 deletions app/core/rwfs/mem.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func (m *MemoryWFS) WriteFile(path string, data []byte, perm fs.FileMode) error
return m.FS.WriteFile(path, data, perm)
}

func (m *MemoryWFS) RunHook(name string, data []byte, args []string) error {
return ErrHooksNotSupported
}

func NewMemoryWFS() *MemoryWFS {
return &MemoryWFS{
FS: memfs.New(),
Expand Down
52 changes: 52 additions & 0 deletions app/core/rwfs/os.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package rwfs

import (
"context"
"io/fs"
"os"
"os/exec"
"os/signal"
"path/filepath"
)

Expand Down Expand Up @@ -34,3 +37,52 @@ func (o *OsWFS) MkdirAll(path string, perm fs.FileMode) error {
func (o *OsWFS) WriteFile(name string, data []byte, perm fs.FileMode) error {
return os.WriteFile(filepath.Join(o.root, name), data, perm)
}

func (o *OsWFS) RunHook(name string, data []byte, args []string) error {
tmp, err := writeHook(name, data)

defer func() {
if rerr := os.Remove(tmp); rerr != nil && err == nil {
err = rerr
}
}()

if err != nil {
return err
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
// stop receiving signal notifications as soon as possible.
<-ctx.Done()
stop()
}()

cmd := exec.CommandContext(ctx, tmp, append([]string{tmp}, args...)...)
cmd.Dir = o.root
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
return err
}

func writeHook(name string, data []byte) (string, error) {
f, err := os.CreateTemp("", name)
if err != nil {
return "", err
}

tmp := f.Name()

err = os.Chmod(tmp, 0o700)
if err != nil {
return tmp, err
}

_, err = f.Write(data)
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
return tmp, err
}
4 changes: 4 additions & 0 deletions app/core/rwfs/rwfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
package rwfs

import (
"errors"
"io/fs"
)

var ErrHooksNotSupported = errors.New("hooks not supported")

// ReadFS is a read only file system that can be used to read files from
// a file system. It is a alias for fs.FS.
type ReadFS = fs.FS
Expand All @@ -16,4 +19,5 @@ type WriteFS interface {
fs.FS
MkdirAll(path string, perm fs.FileMode) error
WriteFile(name string, data []byte, perm fs.FileMode) error
RunHook(name string, data []byte, args []string) error
}
5 changes: 5 additions & 0 deletions app/scaffold/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import (
"github.com/rs/zerolog/log"
)

const (
HooksDir = "hooks"
PostScaffoldScripts = "post_scaffold"
)

var projectNames = [...]string{
"{{ .Project }}",
"{{ .ProjectSlug }}",
Expand Down
57 changes: 55 additions & 2 deletions app/scaffold/rc.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,53 @@ type ScaffoldRC struct {
Auth []AuthEntry `yaml:"auth"`
}

type RunHooksOption string

var (
RunHooksNever RunHooksOption = "never"
RunHooksAlways RunHooksOption = "always"
RunHooksPrompt RunHooksOption = "prompt"
)

func ParseRunHooksOption(s string) RunHooksOption {
zero := RunHooksOption("")
ptr := &zero
_ = ptr.UnmarshalText([]byte(s))
return *ptr
}

func (r *RunHooksOption) UnmarshalText(text []byte) error {
switch string(text) {
case "never", "no", "false":
*r = RunHooksNever
case "always", "yes", "true":
*r = RunHooksAlways
case "prompt", "": // if left empty, default to prompt
*r = RunHooksPrompt
default:
// fallback to whatever they input so we can log the incorrect value
*r = RunHooksOption(string(text))
}

return nil
}

func (r RunHooksOption) IsValid() bool {
switch r {
case RunHooksNever, RunHooksAlways, RunHooksPrompt:
return true
default:
return false
}
}

func (r RunHooksOption) String() string {
return string(r)
}

type Settings struct {
Theme styles.HuhTheme `yaml:"theme"`
Theme styles.HuhTheme `yaml:"theme"`
RunHooks RunHooksOption `yaml:"run_hooks"`
}

type AuthEntry struct {
Expand Down Expand Up @@ -79,7 +124,8 @@ func (e RcValidationErrors) Error() string {
func DefaultScaffoldRC() *ScaffoldRC {
return &ScaffoldRC{
Settings: Settings{
Theme: styles.HuhThemeScaffold,
Theme: styles.HuhThemeScaffold,
RunHooks: RunHooksPrompt,
},
}
}
Expand Down Expand Up @@ -133,6 +179,13 @@ func (rc *ScaffoldRC) Validate() error {
})
}

if !rc.Settings.RunHooks.IsValid() {
errs = append(errs, RCValidationError{
Key: "settings.run_hooks",
Cause: fmt.Errorf("invalid run_hooks: %s", rc.Settings.RunHooks.String()),
})
}

if len(errs) > 0 {
return RcValidationErrors(errs)
}
Expand Down
Loading

0 comments on commit f5c09cd

Please sign in to comment.