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 <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-18 13:34:32 +02:00
parent 846e6d5b74
commit 1edf9d7e4c
6 changed files with 552 additions and 63 deletions

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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}}

View File

@@ -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}}

View File

@@ -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/<locale>/<group>.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