From 1edf9d7e4c35c813f2ff6438c5489ce147cf207b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 13:34:32 +0200 Subject: [PATCH] feat(04-01): scaffold Winter-shaped plugins with generated registry Render plugin.go, routes.go, leaf packages and go.mod from embedded templates, emit empty registry accessors, and define job, admin, lang and mail capability contracts so a new plugin compiles before artifacts. Co-authored-by: Cursor --- internal/build/build_test.go | 133 ++++++++++++++++++++++--- internal/build/registry.go | 95 ++++++++++++++++++ internal/build/scaffold.go | 153 ++++++++++++++++++++--------- internal/build/stubs/plugin.tmpl | 109 ++++++++++++++++++++ internal/build/stubs/registry.tmpl | 73 ++++++++++++++ pact/capabilities.go | 52 +++++++++- 6 files changed, 552 insertions(+), 63 deletions(-) create mode 100644 internal/build/registry.go create mode 100644 internal/build/stubs/plugin.tmpl create mode 100644 internal/build/stubs/registry.tmpl diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 5f95391..dbbff3e 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -337,17 +337,83 @@ func TestParseManifestRejectsUppercaseID(t *testing.T) { } } +func TestScaffoldPluginSmoke(t *testing.T) { + dir := copyHelloApp(t) + ctx := t.Context() + + pluginDir, err := MakePlugin(ctx, dir, "golem15.demo") + if err != nil { + t.Fatal(err) + } + assertScaffoldFiles(t, pluginDir) + + pluginSrc, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) + if err != nil { + t.Fatal(err) + } + regSrc, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "generatedModels()", + "generatedMigrations()", + "generatedCommands()", + "generatedJobs()", + "generatedAdminControllers()", + "//go:embed config", + "//go:embed lang", + "//go:embed views/mail", + } { + if !bytes.Contains(pluginSrc, []byte(want)) { + t.Fatalf("plugin.go missing %s:\n%s", want, pluginSrc) + } + } + for _, want := range []string{ + "func generatedModels()", + "func generatedMigrations()", + "func generatedCommands()", + "func generatedJobs()", + "func generatedAdminControllers()", + } { + if !bytes.Contains(regSrc, []byte(want)) { + t.Fatalf("registry.gen.go missing %s:\n%s", want, regSrc) + } + } + + if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := App(ctx, dir, &buf); err != nil { + t.Fatalf("app build: %v\n%s", err, buf.String()) + } + + vet := execGo(t, pluginDir, "vet", "./...") + if out, err := vet.CombinedOutput(); err != nil { + t.Fatalf("plugin go vet: %v\n%s", err, out) + } + + pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(pluginSrc, pluginAfter) { + t.Fatal("hand-written plugin.go was rewritten") + } + regAfter, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(regSrc, regAfter) { + t.Fatal("registry.gen.go is not byte-stable after add/build") + } +} + func assertScaffoldFiles(t *testing.T, pluginDir string) { t.Helper() - if _, err := os.Stat(filepath.Join(pluginDir, "go.mod")); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(pluginDir, "plugin.go")); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(pluginDir, "config")); err != nil { - t.Fatal(err) - } + assertWinterPluginShape(t, pluginDir) + body, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go")) if err != nil { t.Fatal(err) @@ -375,6 +441,36 @@ func assertScaffoldFiles(t *testing.T, pluginDir string) { t.Fatalf("plugin go.mod missing module path:\n%s", mod) } + cmd := execGo(t, pluginDir, "build", "./...") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("plugin does not compile: %v\n%s", err, out) + } +} + +func assertWinterPluginShape(t *testing.T, pluginDir string) { + t.Helper() + required := []string{ + "go.mod", + "plugin.go", + "routes.go", + "registry.gen.go", + filepath.Join("config", "config.yaml"), + filepath.Join("lang", "en", "lang.yaml"), + filepath.Join("views", "mail", "welcome.htm"), + } + for _, leaf := range pluginLeaves { + required = append(required, filepath.Join(leaf, "doc.go")) + } + allowed := map[string]struct{}{ + "go.sum": {}, + } + for _, rel := range required { + allowed[rel] = struct{}{} + if _, err := os.Stat(filepath.Join(pluginDir, rel)); err != nil { + t.Fatalf("missing %s: %v", rel, err) + } + } + var extra []string if err := filepath.WalkDir(pluginDir, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -387,13 +483,15 @@ func assertScaffoldFiles(t *testing.T, pluginDir string) { if err != nil { return err } - switch rel { - case "go.mod", "go.sum", "plugin.go", filepath.Join("config", ".gitkeep"): + if _, ok := allowed[rel]; ok { return nil - default: + } + if strings.HasPrefix(filepath.Base(rel), "registry.gen.go.") { extra = append(extra, rel) return nil } + extra = append(extra, rel) + return nil }); err != nil { t.Fatal(err) } @@ -401,9 +499,14 @@ func assertScaffoldFiles(t *testing.T, pluginDir string) { t.Fatalf("unexpected scaffold files: %v", extra) } - cmd := execGo(t, pluginDir, "build", "-o", filepath.Join(pluginDir, "demo.bin"), ".") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("plugin does not compile: %v\n%s", err, out) + for _, asset := range []string{ + filepath.Join("config", "config.yaml"), + filepath.Join("lang", "en", "lang.yaml"), + filepath.Join("views", "mail", "welcome.htm"), + } { + if strings.HasPrefix(filepath.Base(asset), ".") || strings.HasPrefix(filepath.Base(asset), "_") { + t.Fatalf("embed-incompatible hidden asset %s", asset) + } } } diff --git a/internal/build/registry.go b/internal/build/registry.go new file mode 100644 index 0000000..8ef3350 --- /dev/null +++ b/internal/build/registry.go @@ -0,0 +1,95 @@ +package build + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" +) + +const registryFileName = "registry.gen.go" + +type registryRef struct { + Package string + Ident string +} + +type registryData struct { + Package string + Module string + Imports []string + Models []registryRef + Migrations []registryRef + Commands []registryRef + Jobs []registryRef + AdminControllers []registryRef +} + +func emptyRegistry(pkg, module string) registryData { + data := registryData{Package: pkg, Module: module} + data.prepare() + return data +} + +func (d *registryData) prepare() { + d.Imports = uniqueLeafImports(d.Module, d.Models, d.Migrations, d.Commands, d.Jobs, d.AdminControllers) +} + +func uniqueLeafImports(module string, groups ...[]registryRef) []string { + if module == "" { + return nil + } + seen := make(map[string]struct{}) + var imports []string + for _, group := range groups { + for _, ref := range group { + if ref.Package == "" { + continue + } + path := module + "/" + ref.Package + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + imports = append(imports, path) + } + } + sort.Strings(imports) + return imports +} + +func writeRegistry(pluginDir string, data registryData) error { + data.prepare() + src, err := renderStub("registry.go", data) + if err != nil { + return err + } + path := filepath.Join(pluginDir, registryFileName) + existing, err := os.ReadFile(path) + if err == nil && bytes.Equal(existing, src) { + return nil + } + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("build: read %s: %w", path, err) + } + tmp, err := os.CreateTemp(pluginDir, registryFileName+".*") + if err != nil { + return fmt.Errorf("build: create %s temp: %w", registryFileName, err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(src); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("build: write %s temp: %w", registryFileName, err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("build: close %s temp: %w", registryFileName, err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("build: rename %s: %w", registryFileName, err) + } + return nil +} diff --git a/internal/build/scaffold.go b/internal/build/scaffold.go index 5b779b5..b0e39a3 100644 --- a/internal/build/scaffold.go +++ b/internal/build/scaffold.go @@ -3,6 +3,7 @@ package build import ( "bytes" "context" + "embed" "fmt" "go/ast" "go/format" @@ -13,8 +14,34 @@ import ( "path/filepath" "strconv" "strings" + "text/template" ) +//go:embed stubs/*.tmpl +var stubFS embed.FS + +var stubTmpl = template.Must(template.ParseFS(stubFS, "stubs/*.tmpl")) + +var pluginLeaves = []string{ + "models", + "classes", + "controllers", + "console", + "jobs", + "middleware", + "updates", +} + +type stubData struct { + Package string + ID string + Module string + Framework string + GoVersion string + Toolchain string + Replace string +} + const ( frameworkModule = "git.golem15.com/golem15/summercms" goVersionLine = "go 1.27.0" @@ -50,27 +77,21 @@ func MakePlugin(ctx context.Context, startDir, id string) (string, error) { return "", fmt.Errorf("build: plugin module: %w", err) } - if err := os.MkdirAll(filepath.Join(pluginDir, "config"), 0o755); err != nil { - return "", fmt.Errorf("build: create plugin dir: %w", err) - } - if err := os.WriteFile(filepath.Join(pluginDir, "config", ".gitkeep"), []byte{}, 0o644); err != nil { - return "", fmt.Errorf("build: write config placeholder: %w", err) - } - - src, err := pluginGoSource(name, id) - if err != nil { - return "", err - } - if err := os.WriteFile(filepath.Join(pluginDir, "plugin.go"), src, 0o644); err != nil { - return "", fmt.Errorf("build: write plugin.go: %w", err) - } - replace, err := frameworkReplaceFor(pluginDir, appDir) if err != nil { return "", err } - if err := os.WriteFile(filepath.Join(pluginDir, "go.mod"), pluginGoMod(modPath, replace), 0o644); err != nil { - return "", fmt.Errorf("build: write go.mod: %w", err) + data := stubData{ + Package: name, + ID: id, + Module: modPath, + Framework: frameworkModule, + GoVersion: goVersionLine, + Toolchain: toolchainLine, + Replace: replace, + } + if err := writePluginScaffold(pluginDir, data); err != nil { + return "", err } if err := runGo(ctx, pluginDir, "mod", "tidy"); err != nil { return "", err @@ -156,39 +177,83 @@ func pluginName(id string) string { return name } -func pluginGoSource(pkg, id string) ([]byte, error) { - var b strings.Builder - b.WriteString("package " + pkg + "\n\n") - b.WriteString("import (\n") - b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n") - b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n") - b.WriteString(")\n\n") - fmt.Fprintf(&b, "// Plugin is the %s plugin.\n", id) - b.WriteString("type Plugin struct{}\n\n") - fmt.Fprintf(&b, "func (p *Plugin) ID() string { return %s }\n", strconv.Quote(id)) - b.WriteString("func (p *Plugin) Requires() []string { return nil }\n\n") - b.WriteString("func (p *Plugin) Register(*backpack.App) error { return nil }\n") - b.WriteString("func (p *Plugin) Boot(*backpack.App) error { return nil }\n\n") - b.WriteString("func init() {\n") - b.WriteString("\tparty.Register(&Plugin{})\n") - b.WriteString("}\n") - formatted, err := format.Source([]byte(b.String())) +func writePluginScaffold(pluginDir string, data stubData) error { + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + return fmt.Errorf("build: create plugin dir: %w", err) + } + pluginSrc, err := renderStub("plugin.go", data) if err != nil { - return nil, fmt.Errorf("build: format plugin.go: %w", err) + return err + } + if err := writeFile(filepath.Join(pluginDir, "plugin.go"), pluginSrc); err != nil { + return err + } + routesSrc, err := renderStub("routes.go", data) + if err != nil { + return err + } + if err := writeFile(filepath.Join(pluginDir, "routes.go"), routesSrc); err != nil { + return err + } + for _, leaf := range pluginLeaves { + src, err := renderStub(leaf+"/doc.go", data) + if err != nil { + return err + } + if err := writeFile(filepath.Join(pluginDir, leaf, "doc.go"), src); err != nil { + return err + } + } + for _, asset := range pluginAssets { + if err := writeFile(filepath.Join(pluginDir, filepath.FromSlash(asset.rel)), []byte(asset.body)); err != nil { + return err + } + } + if err := writeRegistry(pluginDir, emptyRegistry(data.Package, data.Module)); err != nil { + return err + } + modSrc, err := renderStub("go.mod", data) + if err != nil { + return err + } + return writeFile(filepath.Join(pluginDir, "go.mod"), modSrc) +} + +type pluginAsset struct { + rel string + body string +} + +var pluginAssets = []pluginAsset{ + {rel: "config/config.yaml", body: "{}\n"}, + {rel: "lang/en/lang.yaml", body: "{}\n"}, + {rel: "views/mail/welcome.htm", body: "subject = \"Welcome\"\ndescription = \"Placeholder mail template\"\n==\nHello.\n"}, +} + +func renderStub(name string, data any) ([]byte, error) { + var buf bytes.Buffer + if err := stubTmpl.ExecuteTemplate(&buf, name, data); err != nil { + return nil, fmt.Errorf("build: render stub %s: %w", name, err) + } + raw := buf.Bytes() + if !strings.HasSuffix(name, ".go") { + return raw, nil + } + formatted, err := format.Source(raw) + if err != nil { + return nil, fmt.Errorf("build: format stub %s: %w\n%s", name, err, raw) } return formatted, nil } -func pluginGoMod(modPath, replace string) []byte { - var b strings.Builder - fmt.Fprintf(&b, "module %s\n\n", modPath) - b.WriteString(goVersionLine + "\n\n") - b.WriteString(toolchainLine + "\n\n") - fmt.Fprintf(&b, "require %s v0.0.0\n", frameworkModule) - if replace != "" { - fmt.Fprintf(&b, "\nreplace %s => %s\n", frameworkModule, replace) +func writeFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("build: mkdir %s: %w", filepath.Dir(path), err) } - return []byte(b.String()) + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("build: write %s: %w", path, err) + } + return nil } func frameworkReplaceFor(pluginDir, appDir string) (string, error) { diff --git a/internal/build/stubs/plugin.tmpl b/internal/build/stubs/plugin.tmpl new file mode 100644 index 0000000..200dabf --- /dev/null +++ b/internal/build/stubs/plugin.tmpl @@ -0,0 +1,109 @@ +{{define "plugin.go"}}package {{.Package}} + +import ( + "embed" + "io/fs" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" + "github.com/go-gormigrate/gormigrate/v2" +) + +var ( + _ pact.HasConfig = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasMailTemplates = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ pact.HasJobs = (*Plugin)(nil) + _ pact.HasAdminControllers = (*Plugin)(nil) +) + +//go:embed config +var configFS embed.FS + +//go:embed lang +var langFS embed.FS + +//go:embed views/mail +var mailFS embed.FS + +// Plugin is the {{.ID}} plugin. +type Plugin struct{} + +func (p *Plugin) ID() string { return {{printf "%q" .ID}} } +func (p *Plugin) Requires() []string { return nil } + +func (p *Plugin) Register(*backpack.App) error { return nil } +func (p *Plugin) Boot(*backpack.App) error { return nil } + +func (p *Plugin) ConfigFS() fs.FS { return configFS } +func (p *Plugin) LangFS() fs.FS { return langFS } + +func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } +func (p *Plugin) MailTemplates() []string { return nil } +func (p *Plugin) MailLayouts() map[string]string { return nil } + +func (p *Plugin) Models() []any { return generatedModels() } +func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() } +func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() } +func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } +func (p *Plugin) AdminControllers() []pact.AdminController { + return generatedAdminControllers() +} + +func init() { + party.Register(&Plugin{}) +} +{{end}} + +{{define "routes.go"}}package {{.Package}} + +import "git.golem15.com/golem15/summercms/pact" + +var _ pact.HasRoutes = (*Plugin)(nil) + +func (p *Plugin) Routes(pact.Router) error { return nil } +{{end}} + +{{define "go.mod"}}module {{.Module}} + +{{.GoVersion}} + +{{.Toolchain}} + +require {{.Framework}} v0.0.0 +{{if .Replace}} +replace {{.Framework}} => {{.Replace}} +{{end}}{{end}} + +{{define "models/doc.go"}}// Package models holds GORM models for the {{.ID}} plugin. +package models +{{end}} + +{{define "classes/doc.go"}}// Package classes holds services and hooks for the {{.ID}} plugin. +package classes +{{end}} + +{{define "controllers/doc.go"}}// Package controllers holds HTTP handlers and admin controllers for the {{.ID}} plugin. +package controllers +{{end}} + +{{define "console/doc.go"}}// Package console holds bonfire commands for the {{.ID}} plugin. +package console +{{end}} + +{{define "jobs/doc.go"}}// Package jobs holds background jobs for the {{.ID}} plugin. +package jobs +{{end}} + +{{define "middleware/doc.go"}}// Package middleware holds named HTTP middleware for the {{.ID}} plugin. +package middleware +{{end}} + +{{define "updates/doc.go"}}// Package updates holds the gormigrate set for the {{.ID}} plugin. +package updates +{{end}} diff --git a/internal/build/stubs/registry.tmpl b/internal/build/stubs/registry.tmpl new file mode 100644 index 0000000..7ed71f8 --- /dev/null +++ b/internal/build/stubs/registry.tmpl @@ -0,0 +1,73 @@ +{{define "registry.go"}}// Code generated by summer make. DO NOT EDIT. + +package {{.Package}} + +import ( + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/pact" + "github.com/go-gormigrate/gormigrate/v2" +{{- range .Imports}} + {{printf "%q" .}} +{{- end}} +) + +func generatedModels() []any { +{{- if .Models}} + return []any{ + {{- range .Models}} + &{{.Package}}.{{.Ident}}{}, + {{- end}} + } +{{- else}} + return nil +{{- end}} +} + +func generatedMigrations() []*gormigrate.Migration { +{{- if .Migrations}} + return []*gormigrate.Migration{ + {{- range .Migrations}} + {{.Package}}.{{.Ident}}(), + {{- end}} + } +{{- else}} + return nil +{{- end}} +} + +func generatedCommands() []bonfire.Command { +{{- if .Commands}} + return []bonfire.Command{ + {{- range .Commands}} + {{.Package}}.{{.Ident}}(), + {{- end}} + } +{{- else}} + return nil +{{- end}} +} + +func generatedJobs() []pact.Job { +{{- if .Jobs}} + return []pact.Job{ + {{- range .Jobs}} + {{.Package}}.{{.Ident}}(), + {{- end}} + } +{{- else}} + return nil +{{- end}} +} + +func generatedAdminControllers() []pact.AdminController { +{{- if .AdminControllers}} + return []pact.AdminController{ + {{- range .AdminControllers}} + {{.Package}}.{{.Ident}}(), + {{- end}} + } +{{- else}} + return nil +{{- end}} +} +{{end}} diff --git a/pact/capabilities.go b/pact/capabilities.go index a8f44f4..8820257 100644 --- a/pact/capabilities.go +++ b/pact/capabilities.go @@ -1,6 +1,7 @@ package pact import ( + "context" "io/fs" "net/http" @@ -53,6 +54,53 @@ type HasModels interface { Models() []any } +// JobArgs is the typed payload a job worker receives. Kind identifies the +// job so a worker can reject unexpected argument types. +type JobArgs interface { + Kind() string +} + +// Job is a unit of background work. Phase 11 adapts this onto River; the +// interface itself must not import River. +type Job interface { + Work(ctx context.Context, args JobArgs) error +} + +// HasJobs is implemented by plugins that register background jobs. +type HasJobs interface { + Jobs() []Job +} + +// AdminController is the compile-time admin controller contract. Phase 9 +// grows the schema pipeline; ID, model name and YAML config directory are +// enough for generated stubs to compile. +type AdminController interface { + ID() string + ModelName() string + ConfigDir() string +} + +// HasAdminControllers is implemented by plugins that register admin controllers. +type HasAdminControllers interface { + AdminControllers() []AdminController +} + +// HasLang is implemented by plugins that ship embedded translation YAML +// under lang//.yaml. +type HasLang interface { + LangFS() fs.FS +} + +// HasMailTemplates is implemented by plugins that ship Winter-shaped mail +// templates and layout aliases. MailTemplatesFS contains views/mail assets; +// MailTemplates lists dotted template names; MailLayouts maps a short layout +// name to the full dotted layout name. +type HasMailTemplates interface { + MailTemplatesFS() fs.FS + MailTemplates() []string + MailLayouts() map[string]string +} + // OptionalMessage is a service an optional plugin may publish so other // plugins can integrate without importing that plugin's package. type OptionalMessage interface { @@ -62,14 +110,10 @@ type OptionalMessage interface { // Future capability families are type-asserted when their first consumer // packages exist: // -// HasJobs // HasListeners -// HasAdminControllers // HasNavigation // HasPermissions // HasSchedule -// HasMailTemplates -// HasLang // // The kernel type-asserts HasConfig (party, before Register), HasCommands // (generated app main, after Boot), HasMigrations (lagoon migrate), and