Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support post scaffold hooks #167

Merged
merged 9 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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