feat: add scaffolder

This commit is contained in:
2025-02-22 16:08:22 +01:00
commit 01023c212b
31 changed files with 1544 additions and 0 deletions

130
internal/fetcher/fetcher.go Normal file
View File

@@ -0,0 +1,130 @@
package fetcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path"
"time"
)
// Fetcher allows pulling from an upstream scaffold registry. This is hard coded to the lunarway/scaffold registry, it can also be provided by a path which in that case, will not do anything
type Fetcher struct{}
func NewFetcher() *Fetcher {
return &Fetcher{}
}
const readWriteExec = 0o644
const githubProject = "kjuulh/scaffold"
var (
scaffoldFolder = os.ExpandEnv("$HOME/.scaffold")
scaffoldClone = path.Join(scaffoldFolder, "upstream")
scaffoldCache = path.Join(scaffoldFolder, "scaffold.updates.json")
)
func (f *Fetcher) CloneRepository(ctx context.Context, registryPath *string, ui *slog.Logger) error {
if err := os.MkdirAll(scaffoldFolder, readWriteExec); err != nil {
return fmt.Errorf("failed to create scaffold folder: %w", err)
}
if *registryPath == "" {
if _, err := os.Stat(scaffoldClone); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to find the upstream folder: %w", err)
}
ui.Info("cloning upstream templates")
if err := cloneUpstream(ctx); err != nil {
return fmt.Errorf("failed to clone upstream registry: %w", err)
}
} else {
now := time.Now()
lastUpdatedUnix := getCacheUpdate(ui, ctx)
lastUpdated := time.Unix(lastUpdatedUnix, 0)
// Cache for 7 days
if lastUpdated.Before(now.Add(-time.Hour * 24 * 7)) {
ui.Info("update templates folder")
if err := f.UpdateUpstream(ctx); err != nil {
return fmt.Errorf("failed to update upstream scaffold folder: %w", err)
}
}
}
}
return nil
}
func (f *Fetcher) UpdateUpstream(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "git", "pull", "--rebase")
cmd.Dir = scaffoldClone
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("git pull failed with output: %s\n\n", string(output))
return fmt.Errorf("git pull failed: %w", err)
}
if err := createCacheUpdate(ctx); err != nil {
return err
}
return nil
}
func cloneUpstream(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "coffee", "repo", "clone", githubProject, scaffoldClone)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("git clone failed with output: %s\n\n", string(output))
return fmt.Errorf("git clone failed: %w", err)
}
if err := createCacheUpdate(ctx); err != nil {
return err
}
return nil
}
type CacheUpdate struct {
LastUpdated int64 `json:"lastUpdated"`
}
func createCacheUpdate(_ context.Context) error {
content, err := json.Marshal(CacheUpdate{
LastUpdated: time.Now().Unix(),
})
if err != nil {
return fmt.Errorf("failed to prepare cache update: %w", err)
}
if err := os.WriteFile(scaffoldCache, content, readWriteExec); err != nil {
return fmt.Errorf("failed to write cache update: %w", err)
}
return nil
}
func getCacheUpdate(ui *slog.Logger, _ context.Context) int64 {
content, err := os.ReadFile(scaffoldCache)
if err != nil {
return 0
}
var cacheUpdate CacheUpdate
if err := json.Unmarshal(content, &cacheUpdate); err != nil {
ui.Warn("failed to read cache, it might be invalid", "error", err)
return 0
}
return cacheUpdate.LastUpdated
}

View File

@@ -0,0 +1,163 @@
package templates
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"sync"
gotmpl "text/template"
"golang.org/x/sync/errgroup"
)
// TemplateLoader reads a templates files and runs their respective templating on them.
type TemplateLoader struct{}
func NewTemplateLoader() *TemplateLoader {
return &TemplateLoader{}
}
type File struct {
content []byte
path string
RelPath string
}
var funcs = gotmpl.FuncMap{
"ReplaceAll": strings.ReplaceAll,
"ToLower": strings.ToLower,
"ToUpper": strings.ToUpper,
}
// TemplatePath formats the template file path using go templates, this is useful for programmatically changing the output string using go tmpls
func TemplatePath(template *Template) (string, error) {
tmpl, err := gotmpl.New("path").Funcs(funcs).Parse(template.File.Default.Path)
if err != nil {
return "", err
}
output := bytes.NewBufferString("")
if err := tmpl.Execute(output, template); err != nil {
return "", err
}
templatePath := strings.TrimSpace(output.String())
return templatePath, nil
}
// Load loads the template files from disk
func (t *TemplateLoader) Load(ctx context.Context, template *Template) ([]File, error) {
templateFilePath := path.Join(template.Path, "files")
if _, err := os.Stat(templateFilePath); err != nil {
return nil, fmt.Errorf("failed to lookup template files %s, %w", templateFilePath, err)
}
filePaths := make([]string, 0)
err := filepath.WalkDir(templateFilePath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Is a file
if d.Type().IsRegular() {
filePaths = append(filePaths, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to read template files: %w", err)
}
var (
filesLock sync.Mutex
files = make([]File, 0)
)
egrp, _ := errgroup.WithContext(ctx)
for _, filePath := range filePaths {
egrp.Go(func() error {
fileContent, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file: %s, %w", filePath, err)
}
filesLock.Lock()
defer filesLock.Unlock()
files = append(files, File{
content: fileContent,
path: filePath,
RelPath: strings.TrimPrefix(strings.TrimPrefix(filePath, templateFilePath), "/"),
})
return nil
})
}
if err := egrp.Wait(); err != nil {
return nil, err
}
return files, nil
}
type TemplatedFile struct {
Content []byte
DestinationPath string
}
// TemplateFiles runs the actual templating on the files, and tells it where to go. The writes doesn't happen here yet.
func (l *TemplateLoader) TemplateFiles(template *Template, files []File, scaffoldDest string) ([]TemplatedFile, error) {
templatedFiles := make([]TemplatedFile, 0)
for _, file := range files {
tmpl, err := gotmpl.
New(file.RelPath).
Funcs(funcs).
Parse(string(file.content))
if err != nil {
return nil, fmt.Errorf("failed to parse template file: %s, %w", file.RelPath, err)
}
output := bytes.NewBufferString("")
if err := tmpl.Execute(output, template); err != nil {
return nil, fmt.Errorf("failed to write template file: %s, %w", file.RelPath, err)
}
fileDir := path.Dir(file.RelPath)
fileName := strings.TrimSuffix(path.Base(file.RelPath), ".gotmpl")
if fileConfig, ok := template.File.Files[strings.TrimSuffix(file.RelPath, ".gotmpl")]; ok && fileConfig.Rename != "" {
renameTmpl, err := gotmpl.New(file.RelPath).Funcs(funcs).Parse(fileConfig.Rename)
if err != nil {
return nil, fmt.Errorf("failed to parse rename for: %s in scaffold.yaml: %w", file.RelPath, err)
}
type RenameContext struct {
Template
OriginalFileName string
}
output := bytes.NewBufferString("")
if err := renameTmpl.Execute(output, RenameContext{
Template: *template,
OriginalFileName: fileName,
}); err != nil {
return nil, fmt.Errorf("failed to template rename: %s, %w", file.RelPath, err)
}
fileName = strings.TrimSpace(output.String())
}
templatedFiles = append(templatedFiles, TemplatedFile{
Content: output.Bytes(),
DestinationPath: path.Join(scaffoldDest, fileDir, fileName),
})
}
return templatedFiles, nil
}

View File

@@ -0,0 +1,97 @@
package templates
import (
"context"
"fmt"
"log/slog"
"os"
"path"
"sync"
"golang.org/x/sync/errgroup"
"gopkg.in/yaml.v3"
)
// TemplateIndexer loads all template specifications from the registry, allowing the caller to choose on of them, or simply display their properties.
type TemplateIndexer struct{}
func NewTemplateIndexer() *TemplateIndexer {
return &TemplateIndexer{}
}
type Template struct {
File TemplateFile
Path string
Input map[string]string
}
type TemplateDefault struct {
Path string `yaml:"path"`
}
type TemplateInputs map[string]TemplateInput
type TemplateInput struct {
Type string `yaml:"type"`
Description string `yaml:"description"`
Default string `yaml:"default"`
}
type TemplateFileConfig struct {
Rename string `yaml:"rename"`
}
type TemplateFile struct {
Name string `yaml:"name"`
Default TemplateDefault `yaml:"default"`
Input TemplateInputs `yaml:"input"`
Files map[string]TemplateFileConfig
}
func (t *TemplateIndexer) Index(ctx context.Context, scaffoldRegistryFolder string, ui *slog.Logger) ([]Template, error) {
ui.Debug("Loading templates...")
templateDirEntries, err := os.ReadDir(scaffoldRegistryFolder)
if err != nil {
return nil, fmt.Errorf("failed to read templates dir: %w", err)
}
var (
templates = make([]Template, 0)
templatesLock sync.Mutex
)
egrp, _ := errgroup.WithContext(ctx)
for _, templateDirEntry := range templateDirEntries {
egrp.Go(func() error {
templatePath := path.Join(scaffoldRegistryFolder, templateDirEntry.Name())
content, err := os.ReadFile(path.Join(templatePath, "scaffold.yaml"))
if err != nil {
return fmt.Errorf("failed to read: %s, %w", templateDirEntry.Name(), err)
}
var template TemplateFile
if err := yaml.Unmarshal(content, &template); err != nil {
return fmt.Errorf("failed to unmarshal template: %s, %w", string(content), err)
}
templatesLock.Lock()
defer templatesLock.Unlock()
templates = append(templates, Template{
File: template,
Path: templatePath,
Input: make(map[string]string),
})
return nil
})
}
if err := egrp.Wait(); err != nil {
return nil, err
}
ui.Debug("Done loading templates...", "amount", len(templates))
return templates, nil
}

View File

@@ -0,0 +1,84 @@
package templates
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path"
"sync"
"golang.org/x/sync/errgroup"
)
const readWriteExec = 0o755
const readExec = 0o644
type PromptOverride func(file TemplatedFile) (bool, error)
// FileWriter writes the actual files to disk, it optionally takes a promptOverride which allows the caller to stop a potential override of a file
type FileWriter struct {
promptOverride PromptOverride
}
func NewFileWriter() *FileWriter {
return &FileWriter{
promptOverride: nil,
}
}
func (f *FileWriter) WithPromptOverride(po PromptOverride) *FileWriter {
f.promptOverride = po
return f
}
func (f *FileWriter) Write(ctx context.Context, ui *slog.Logger, templatedFiles []TemplatedFile) error {
var fileExistsLock sync.Mutex
egrp, _ := errgroup.WithContext(ctx)
for _, file := range templatedFiles {
egrp.Go(func() error {
if _, err := os.Stat(file.DestinationPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check if file exists: %s, %w", file.DestinationPath, err)
}
} else {
if f.promptOverride != nil {
fileExistsLock.Lock()
defer fileExistsLock.Unlock()
override, err := f.promptOverride(file)
if err != nil {
return fmt.Errorf("failed to get answer to whether a file should be overwritten or not: %w", err)
}
if !override {
ui.Warn("Skipping file", "file", file.DestinationPath)
return nil
}
}
}
if parent := path.Dir(file.DestinationPath); parent != "" && parent != "/" {
if err := os.MkdirAll(parent, readWriteExec); err != nil {
return fmt.Errorf("failed to create parent dir for: %s, %w", file.DestinationPath, err)
}
}
ui.Info("writing file", "path", file.DestinationPath)
if err := os.WriteFile(file.DestinationPath, file.Content, readExec); err != nil {
return fmt.Errorf("failed to write file: %s, %w", file.DestinationPath, err)
}
return nil
})
}
if err := egrp.Wait(); err != nil {
return err
}
return nil
}

206
internal/tests/fixture.go Normal file
View File

@@ -0,0 +1,206 @@
package tests
import (
"fmt"
"io/fs"
"log/slog"
"os"
"path"
"path/filepath"
"slices"
"strings"
"testing"
"git.front.kjuulh.io/kjuulh/scaffold/internal/templates"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ScaffoldFixture provides an api on top of the scaffold templater, this is opposed to calling the cli
type ScaffoldFixture struct {
vars map[string]string
path string
}
func (s *ScaffoldFixture) WithVariable(key, val string) *ScaffoldFixture {
s.vars[key] = val
return s
}
func (s *ScaffoldFixture) WithPath(path string) *ScaffoldFixture {
s.path = path
return s
}
// TestFixture is an opinionated way for the templates to be able to test their code, this also works as an accepttest for the scaffolder itself.
type TestFixture struct {
pkg string
t *testing.T
}
func Test(t *testing.T, pkg string) *TestFixture {
return &TestFixture{
pkg: pkg,
t: t,
}
}
// ScaffoldDefaultTest tests that the code can be run with the default variables. We want to have sane defaults for most things. As such there is an opinionated way of running these tests
func (f *TestFixture) ScaffoldDefaultTest(testName string) *TestFixture {
f.ScaffoldTest(testName, func(fixture *ScaffoldFixture) {})
return f
}
// ScaffoldTest is a large fixture, which allows running the accepttest over a template, in turn creating files and comparing them. either way they have to match, if the templater generated more files we expect, it fails, if they're different the test fails
func (f *TestFixture) ScaffoldTest(testName string, input func(fixture *ScaffoldFixture)) *TestFixture {
f.t.Run(
f.pkg,
func(t *testing.T) {
t.Parallel()
testName := strings.ToLower(strings.ReplaceAll(testName, " ", "_"))
fixture := &ScaffoldFixture{
vars: make(map[string]string),
}
input(fixture)
ctx := t.Context()
indexer := templates.NewTemplateIndexer()
loader := templates.NewTemplateLoader()
writer := templates.NewFileWriter()
ui := slog.New(slog.NewTextHandler(os.Stderr, nil))
templateFiles, err := indexer.Index(ctx, "../", ui)
require.NoError(t, err)
template, err := find(templateFiles, f.pkg)
require.NoError(t, err, "failed to find a template")
files, err := loader.Load(ctx, template)
require.NoError(t, err, "failed to load template files")
for input, inputSpec := range template.File.Input {
template.Input[input] = inputSpec.Default
}
for key, val := range fixture.vars {
template.Input[key] = val
}
templatePath, err := templates.TemplatePath(template)
require.NoError(t, err)
if fixture.path != "" {
templatePath = fixture.path
}
actualPath := path.Join("testdata", testName, "actual")
expectedPath := path.Join("testdata", testName, "expected")
templatedFiles, err := loader.TemplateFiles(template, files, path.Join(actualPath, templatePath))
require.NoError(t, err, "failed to template files")
err = os.RemoveAll(actualPath)
require.NoError(t, err)
err = writer.Write(ctx, ui, templatedFiles)
require.NoError(t, err, "failed to write files")
actualFiles, err := getFiles(actualPath)
require.NoError(t, err, "failed to get actual files")
expectedFiles, err := getFiles(expectedPath)
assert.NoError(t, err, "failed to get expected files")
slices.Sort(actualFiles)
slices.Sort(expectedFiles)
assert.Equal(
t,
makeRelative(expectedPath, expectedFiles),
makeRelative(actualPath, actualFiles),
"expected and actual files didn't match",
)
compareFiles(t,
expectedPath, actualPath,
expectedFiles, actualFiles,
)
})
return f
}
func compareFiles(t *testing.T, expectedPath, actualPath string, expectedFiles, actualFiles []string) {
expectedRelativeFiles := makeRelative(expectedPath, expectedFiles)
actualRelativeFiles := makeRelative(actualPath, actualFiles)
for expectedIndex, expectedRelativeFile := range expectedRelativeFiles {
for actualIndex, actualRelativeFile := range actualRelativeFiles {
if expectedRelativeFile == actualRelativeFile {
expectedFilePath := expectedFiles[expectedIndex]
actualFilePath := actualFiles[actualIndex]
expectedFile, err := os.ReadFile(expectedFilePath)
require.NoError(t, err, "failed to read expected file")
actualFile, err := os.ReadFile(actualFilePath)
require.NoError(t, err, "failed to read actual file")
assert.Equal(t,
string(expectedFile), string(actualFile),
"expected and actual file doesn't match\n\texpected path=%s\n\t actual path=%s",
expectedFilePath, actualFilePath,
)
}
}
}
}
// makeRelative, test files are prefixed with either actual or expected, this makes it hard to compare, this makes them comparable by removing their unique folder prefix.
func makeRelative(prefix string, filePaths []string) []string {
output := make([]string, 0, len(filePaths))
for _, filePath := range filePaths {
relative := strings.TrimPrefix(strings.TrimPrefix(filePath, prefix), "/")
output = append(output, relative)
}
return output
}
func find(templates []templates.Template, templateName string) (*templates.Template, error) {
templateNames := make([]string, 0)
for _, template := range templates {
if template.File.Name == templateName {
return &template, nil
}
templateNames = append(templateNames, template.File.Name)
}
return nil, fmt.Errorf("template was not found: %s", strings.Join(templateNames, ", "))
}
func getFiles(root string) ([]string, error) {
actualFiles := make([]string, 0)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
actualFiles = append(actualFiles, path)
}
return nil
})
if err != nil {
return nil, err
}
return actualFiles, nil
}