feat: add scaffolder

This commit is contained in:
Kasper Juul Hermansen 2025-02-22 16:08:22 +01:00
commit 01023c212b
Signed by: kjuulh
SSH Key Fingerprint: SHA256:RjXh0p7U6opxnfd3ga/Y9TCo18FYlHFdSpRIV72S/QM
31 changed files with 1544 additions and 0 deletions

2
.drone.yml Normal file
View File

@ -0,0 +1,2 @@
kind: template
load: cuddle-empty-plan.yaml

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
.settings
.git
.idea
*.iml
.vscode
.DS_Store
reports/*
vendor
buildInfo.json
/.shuttle/
/.dockerignore
/main
/local.env
env.local
registry/*/testdata/*/actual/

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# Scaffold
Scaffold is a cli that allows Developer to easily scaffold (create or update files) according to best practices.
## Usage
```bash
scaffold
> Pick a template
> Fill required information for the template
> Profit
```
Optionally if you know what you're looking for:
```bash
scaffold externalhttp # --package app as an example
```
Scaffold allows a wide variety of formatting options, such as template defined inputs, where the files should be placed, if they should be overwritten or not.
## Develop your own template
Templates are maintained in the `registry` folder. This is automatically kept up-to-date by the `scaffold`, in the folder you will see all the available templates.
To develop your own
```
go run ./main.go --registry registry scaffold --name your_scaffold_here
```
Scaffold will now have created a sample scaffold in the `registry/your_scaffold_here` folder along with tests.
A template consists of the following files:
- `scaffold.yaml`: Controls how the scaffold is supposed to work, which inputs it has, etc.
- `scaffold_test.go`: Optional, but recommended tests which runs a set of input on the template and checks the output
- `files/*.gotmpl`: Files to be scaffolded, it is recommended to provide a suffix of `.gotmpl` to the files, especially for golang files, which might otherwise mess with the project. Each file is templated using go templates, and is put in the path specified by the user, or via. the default path from the scaffold file. Files can also be put in directories, and the folder structure will be preserved.
- `testdata/your_test_here/actual && expected`: contains tests to match the output of the scaffolder. This is especially useful to test a variety of input, for example using the defaults, vs. getting other info from the user.
To make your plugin available, simply create a pr on this repository, merge and your users should have it available shortly

96
cmd/new.go Normal file
View File

@ -0,0 +1,96 @@
package cmd
import (
"context"
"fmt"
"log/slog"
"os"
"git.front.kjuulh.io/kjuulh/scaffold/internal/templates"
"github.com/spf13/cobra"
)
func getScaffoldCommands(registryPath *string) ([]*cobra.Command, error) {
var (
ctx = context.Background()
ui = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{}))
templateIndexer = templates.NewTemplateIndexer()
templateLoader = templates.NewTemplateLoader()
fileWriter = templates.NewFileWriter().WithPromptOverride(promptOverrideFile)
)
templateFiles, err := templateIndexer.Index(ctx, *registryPath, ui)
if err != nil {
return nil, fmt.Errorf("failed to index templates: %w", err)
}
commands := make([]*cobra.Command, 0)
for _, template := range templateFiles {
var templatePath string
variables := make([]*LazyVariable, 0)
for name, variable := range template.File.Input {
variables = append(variables, &LazyVariable{
Name: name,
Description: variable.Description,
Value: variable.Default,
})
}
cmd := &cobra.Command{
Use: template.File.Name,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
ui.Info("Loading template files", "name", template.File.Name)
for _, variable := range variables {
ui.Info("found value", "key", variable.Name, "value", variable.Value)
template.Input[variable.Name] = variable.Value
}
if templatePath == "" {
scaffoldDest, err := templates.TemplatePath(&template)
if err != nil {
return err
}
templatePath = scaffoldDest
}
files, err := templateLoader.Load(ctx, &template)
if err != nil {
return fmt.Errorf("failed to load template files: %w", err)
}
templatedFiles, err := templateLoader.TemplateFiles(&template, files, templatePath)
if err != nil {
return fmt.Errorf("failed to template files: %w", err)
}
ui.Info("Templated files", "files", len(templatedFiles))
if err := fileWriter.Write(ctx, ui, templatedFiles); err != nil {
return fmt.Errorf("failed to write files: %w", err)
}
return nil
},
}
cmd.Flags().StringVar(&templatePath, "path", "", "which path to put the output files")
for _, variable := range variables {
cmd.Flags().StringVar(&variable.Value, variable.Name, variable.Value, variable.Description)
}
commands = append(commands, cmd)
}
return commands, nil
}
type LazyVariable struct {
Name string
Description string
Value string
}

241
cmd/root.go Normal file
View File

@ -0,0 +1,241 @@
package cmd
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path"
"strconv"
"strings"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/ktr0731/go-fuzzyfinder"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"git.front.kjuulh.io/kjuulh/scaffold/internal/fetcher"
"git.front.kjuulh.io/kjuulh/scaffold/internal/templates"
)
func Execute() error {
var registryPath string
rootCmd := &cobra.Command{
Use: "scaffold",
Short: "pick a template, and scaffold a piece of code",
Run: func(cmd *cobra.Command, args []string) {
if err := runScaffold(cmd.Context(), &registryPath); err != nil {
fmt.Printf("failed to run scaffold: %s\n", err.Error())
os.Exit(1)
}
},
}
rootCmd.PersistentFlags().StringVar(&registryPath, "registry", "", "where to get the registry from, defaults to upstream repository")
_ = rootCmd.ParseFlags(os.Args)
subCommands, err := getScaffoldCommands(&registryPath)
if err != nil {
fmt.Printf("failed to setup subcommands: %s\n", err.Error())
os.Exit(1)
}
if len(subCommands) > 0 {
rootCmd.AddCommand(subCommands...)
}
return rootCmd.Execute()
}
func runScaffold(ctx context.Context, registryPath *string) error {
ui := slog.New(slog.NewTextHandler(os.Stderr, nil))
fetcher := fetcher.NewFetcher()
templateIndexer := templates.NewTemplateIndexer()
templateLoader := templates.NewTemplateLoader()
fileWriter := templates.NewFileWriter().WithPromptOverride(promptOverrideFile)
if err := fetcher.CloneRepository(ctx, registryPath, ui); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
templates, err := templateIndexer.Index(ctx, *registryPath, ui)
if err != nil {
return fmt.Errorf("failed to index templates: %w", err)
}
template, err := chooseTemplate(templates)
if err != nil {
return fmt.Errorf("failed to choose a template: %w", err)
}
ui.Info("Loading template files", "name", template.File.Name)
files, err := templateLoader.Load(ctx, template)
if err != nil {
return fmt.Errorf("failed to load template files: %w", err)
}
ui.Info("Loaded templates", "files", len(files))
scaffoldDest, err := promptInput(template, files)
if err != nil {
return fmt.Errorf("failed to prompt input: %w", err)
}
ui.Info("Templating files")
templatedFiles, err := templateLoader.TemplateFiles(template, files, scaffoldDest)
if err != nil {
return fmt.Errorf("failed to template files: %w", err)
}
ui.Info("Templated files", "files", len(templatedFiles))
if err := fileWriter.Write(ctx, ui, templatedFiles); err != nil {
return fmt.Errorf("failed to write files: %w", err)
}
return nil
}
func promptOverrideFile(file templates.TemplatedFile) (bool, error) {
theme := huh.ThemeBase16()
theme.FieldSeparator = lipgloss.NewStyle().SetString("\n")
theme.Help.FullKey.MarginTop(1)
confirm := false
f := huh.
NewForm(
huh.
NewGroup(
huh.
NewConfirm().
Title(fmt.Sprintf("Should override existing file?: %s", file.DestinationPath)).
Value(&confirm),
),
).
WithTheme(theme)
err := f.Run()
if err != nil {
return false, fmt.Errorf("failed to specify path for scaffold: %w", err)
}
return confirm, nil
}
func promptInput(template *templates.Template, files []templates.File) (string, error) {
if len(template.File.Input) == 0 {
return "", nil
}
theme := huh.ThemeBase16()
theme.FieldSeparator = lipgloss.NewStyle().SetString("\n")
theme.Help.FullKey.MarginTop(1)
for input, inputSpec := range template.File.Input {
inputVal := inputSpec.Default
f := huh.
NewForm(
huh.
NewGroup(
huh.
NewText().
TitleFunc(
func() string {
return fmt.Sprintf("Template requires: %s", input)
},
&inputVal,
).
Value(&inputVal).
Description(inputSpec.Description).
WithHeight(1),
),
).
WithTheme(theme)
err := f.Run()
if err != nil {
return "", fmt.Errorf("failed to find template variable: %s, %w", input, err)
}
if inputSpec.Type == "int" {
if _, err := strconv.Atoi(inputVal); err != nil {
return "", fmt.Errorf("input: '%s' for variable: '%s' is not an int: \n%w", inputVal, input, err)
}
}
template.Input[input] = inputVal
}
scaffoldDest, err := templates.TemplatePath(template)
if err != nil {
return "", fmt.Errorf("failed to template path: %w", err)
}
f := huh.
NewForm(
huh.
NewGroup(
huh.
NewText().
Title("Path: where to scaffold files: %s").
Value(&scaffoldDest).
DescriptionFunc(func() string {
var sb strings.Builder
_, err := sb.WriteString("Preview of file paths:\n")
if err != nil {
panic(err)
}
for _, file := range files {
previewFilePath := path.Join(scaffoldDest, file.RelPath)
if _, err := sb.WriteString(fmt.Sprintf("%s\n", previewFilePath)); err != nil {
panic(err)
}
}
return sb.String()
}, &scaffoldDest),
),
).
WithTheme(theme)
err = f.Run()
if err != nil {
return "", fmt.Errorf("failed to specify path for scaffold: %w", err)
}
if scaffoldDest == "" {
return "", errors.New("path cannot be an empty string")
}
return scaffoldDest, nil
}
func chooseTemplate(templates []templates.Template) (*templates.Template, error) {
idx, err := fuzzyfinder.Find(
templates,
func(i int) string {
return templates[i].File.Name
},
fuzzyfinder.WithPreviewWindow(func(i, w, h int) string {
if i == -1 {
return ""
}
template := templates[i]
templateContent, err := yaml.Marshal(template.File)
if err != nil {
return fmt.Sprintf("failed to format template: %s", err.Error())
}
return fmt.Sprintf("Template:\n===\n%s\n===\n", string(templateContent))
}))
if err != nil {
return nil, fmt.Errorf("failed to find a template: %w", err)
}
return &templates[idx], nil
}

16
cuddle.yaml Normal file
View File

@ -0,0 +1,16 @@
# yaml-language-server: $schema=https://git.front.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
base: "git@git.front.kjuulh.io:kjuulh/cuddle-empty-plan.git"
vars:
service: "scaffold"
registry: kasperhermansen
please:
project:
owner: kjuulh
repository: "scaffold"
branch: main
settings:
api_url: https://git.front.kjuulh.io

74
go.mod Normal file
View File

@ -0,0 +1,74 @@
module git.front.kjuulh.io/kjuulh/scaffold
go 1.22
toolchain go1.23.1
require (
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v1.0.0
github.com/ktr0731/go-fuzzyfinder v0.8.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
git.front.kjuulh.io/kjuulh/scaffoldhttp v1.9.0
git.front.kjuulh.io/kjuulh/scaffoldui-logger v0.1.0
golang.org/x/sync v0.11.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v0.20.0 // indirect
github.com/charmbracelet/bubbletea v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.4.2 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/gdamore/tcell/v2 v2.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/errors v0.22.0 // indirect
github.com/gofrs/uuid/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/ktr0731/go-ansisgr v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nsf/termbox-go v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
git.front.kjuulh.io/kjuulh/scaffoldcorrelation v1.0.1 // indirect
git.front.kjuulh.io/kjuulh/scaffoldzlog v1.3.2 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.uber.org/zap/exp v0.2.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

189
go.sum Normal file
View File

@ -0,0 +1,189 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk=
github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg=
github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk=
github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ktr0731/go-ansisgr v0.1.0 h1:fbuupput8739hQbEmZn1cEKjqQFwtCCZNznnF6ANo5w=
github.com/ktr0731/go-ansisgr v0.1.0/go.mod h1:G9lxwgBwH0iey0Dw5YQd7n6PmQTwTuTM/X5Sgm/UrzE=
github.com/ktr0731/go-fuzzyfinder v0.8.0 h1:+yobwo9lqZZ7jd1URPdCgZXTE2U1mpIVTkQoo4roi6w=
github.com/ktr0731/go-fuzzyfinder v0.8.0/go.mod h1:Bjpz5im+tppKE9Ii6UK1h+6RaX/lUvJ0ruO4LIYRkqo=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
git.front.kjuulh.io/kjuulh/scaffoldcorrelation v1.0.1 h1:6cZ+bbliY1xtXYoWom+p/vBWpGcASHxk0TOsruzOEmE=
git.front.kjuulh.io/kjuulh/scaffoldcorrelation v1.0.1/go.mod h1:isjTaeBzq9bqFopUiDThuy4ts3wOMC4MWEnyKBAru7E=
git.front.kjuulh.io/kjuulh/scaffoldhttp v1.9.0 h1:QUXeztzoE/sX36iRaC26RWdCd5bhRAJ9pPqiOiliObI=
git.front.kjuulh.io/kjuulh/scaffoldhttp v1.9.0/go.mod h1:1nc+PsFOW68wf8AwkrL4F58zXFB/lEFCuMn0jK3FqNs=
git.front.kjuulh.io/kjuulh/scaffoldui-logger v0.1.0 h1:xvoi3lSoHR7wmIBLmU7TiLc8NZ2EnDP6pl250/vyMCo=
git.front.kjuulh.io/kjuulh/scaffoldui-logger v0.1.0/go.mod h1:wiXZzc0VZBXByAgNHQB67HWdHdaVhC+EA+BC0OcM4q0=
git.front.kjuulh.io/kjuulh/scaffoldzlog v1.3.2 h1:SC2aMj9rLpSIwa/IS57PDjFWELqcYhFgct6qNtkZUdU=
git.front.kjuulh.io/kjuulh/scaffoldzlog v1.3.2/go.mod h1:gmLlCoBM2FHYOg7TJM/i9abwU9Q/owAztCbbqkeyEPs=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs=
go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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
}

15
main.go Normal file
View File

@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"git.front.kjuulh.io/kjuulh/scaffold/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
fmt.Printf("scaffold failed: %s\n", err.Error())
os.Exit(1)
}
}

View File

@ -0,0 +1,9 @@
package {{ "{{ .Input.package }}" }}
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,8 @@
name: {{ ReplaceAll .Input.name "-" "_" }}
default:
path: internal/app/
input:
package:
type: string
description: "Which go pkg to use for the generated file"
default: "app"

View File

@ -0,0 +1,18 @@
package {{ ReplaceAll .Input.name "-" "" }}
import (
"testing"
"git.front.kjuulh.io/kjuulh/scaffold/internal/tests"
)
func TestScaffold(t *testing.T) {
tests.
Test(t, "{{ ReplaceAll .Input.name "-" "" }}").
ScaffoldDefaultTest("default").
ScaffoldTest("scaffold package with name",
func(fixture *tests.ScaffoldFixture) {
fixture.WithVariable("package", "somename")
},
)
}

View File

@ -0,0 +1,9 @@
package app
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,9 @@
package app
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,9 @@
package something
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,9 @@
package somename
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,8 @@
name: scaffold
default:
path: >
registry/{{ ReplaceAll .Input.name "-" "_" }}
input:
name:
type: string
description: "which name to use for the scaffold"

View File

@ -0,0 +1,17 @@
package externalhttp_test
import (
"testing"
"git.front.kjuulh.io/kjuulh/scaffold/internal/tests"
)
func TestScaffold(t *testing.T) {
tests.
Test(t, "scaffold").
ScaffoldTest("scaffold itself",
func(fixture *tests.ScaffoldFixture) {
fixture.WithVariable("name", "some-name")
},
)
}

View File

@ -0,0 +1,9 @@
package {{ .Input.package }}
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,8 @@
name: some_name
default:
path: internal/app/
input:
package:
type: string
description: "Which go pkg to use for the generated file"
default: "app"

View File

@ -0,0 +1,18 @@
package somename
import (
"testing"
"git.front.kjuulh.io/kjuulh/scaffold/internal/tests"
)
func TestScaffold(t *testing.T) {
tests.
Test(t, "somename").
ScaffoldDefaultTest("default").
ScaffoldTest("scaffold package with name",
func(fixture *tests.ScaffoldFixture) {
fixture.WithVariable("package", "somename")
},
)
}

View File

@ -0,0 +1,9 @@
package app
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,9 @@
package app
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,9 @@
package something
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

View File

@ -0,0 +1,9 @@
package somename
import "git.front.kjuulh.io/kjuulh/scaffoldhttp"
func externalHttpServer() func() (*http.ExternalServer, error) {
return func() (*http.ExternalServer, error) {
return http.NewExternal(), nil
}
}

7
renovate.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>lunarway/renovate-config",
"local>lunarway/renovate-config//presets/golang-libs"
]
}