feat(04-01): generate jobs and admin controllers, reject model imports

Scaffold pact.Job and pact.AdminController stubs with Winter YAML
assets, and fail summer build when models imports a sibling package.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-18 13:41:13 +02:00
parent d8c56509f2
commit c87bbc37fb
7 changed files with 476 additions and 1 deletions

View File

@@ -31,6 +31,8 @@ func toolCommands() []bonfire.Command {
makeModelCommand(),
makeMigrationCommand(),
makeCommandCommand(),
makeJobCommand(),
makeAdminControllerCommand(),
addPluginCommand(),
devCommand(),
parityProxyCommand(),
@@ -159,6 +161,52 @@ func makeCommandCommand() bonfire.Command {
}
}
func makeJobCommand() bonfire.Command {
return bonfire.Command{
Name: "make:job",
Description: "Generate a plugin job without importing River",
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
if err != nil {
return err
}
id, name, err := resolveMakeArgs(in, "make:job")
if err != nil {
return err
}
result, err := build.MakeJob(ctx, dir, id, name)
if err != nil {
return err
}
return printArtifact(out, result)
},
}
}
func makeAdminControllerCommand() bonfire.Command {
return bonfire.Command{
Name: "make:admin-controller",
Description: "Generate an admin controller with fields.yaml and columns.yaml",
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, err := os.Getwd()
if err != nil {
return err
}
id, name, err := resolveMakeArgs(in, "make:admin-controller")
if err != nil {
return err
}
result, err := build.MakeAdminController(ctx, dir, id, name)
if err != nil {
return err
}
return printArtifact(out, result)
},
}
}
func makeArtifactArgs() []bonfire.Arg {
return []bonfire.Arg{
{Name: "plugin", Description: "Plugin ID in vendor.plugin form, or the artifact name when already inside the plugin"},

View File

@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
for _, c := range toolCommands() {
names = append(names, c.Name)
}
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve"} {
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve"} {
if !slices.Contains(names, want) {
t.Fatalf("missing %s in %v", want, names)
}

View File

@@ -24,6 +24,9 @@ type artifactData struct {
Ident string
Table string
Func string
Worker string
Kind string
ConfigDir string
MigrationID string
UpSQL string
DownSQL string
@@ -187,6 +190,124 @@ func MakeCommand(ctx context.Context, startDir, pluginID, name string) (Artifact
return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil
}
// MakeJob writes a pact.Job stub without importing River.
func MakeJob(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) {
plugin, err := resolvePlugin(startDir, pluginID)
if err != nil {
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: job: %w", err)
}
fn := name + "Job"
if err := rejectDuplicateIdent(plugin.Dir, "jobs", fn); err != nil {
return ArtifactResult{}, err
}
rel := filepath.Join("jobs", toSnake(name)+".go")
if err := rejectDuplicateFile(plugin.Dir, rel); err != nil {
return ArtifactResult{}, err
}
src, err := renderStub("job.go", artifactData{
Ident: name,
Func: fn,
Worker: unexportedIdent(name) + "Job",
Kind: plugin.ID + "." + toSnake(name),
})
if err != nil {
return ArtifactResult{}, err
}
path := filepath.Join(plugin.Dir, rel)
if err := writeFile(path, src); err != nil {
return ArtifactResult{}, err
}
if err := finishArtifact(ctx, plugin); err != nil {
os.Remove(path)
return ArtifactResult{}, err
}
return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil
}
// MakeAdminController writes a pact.AdminController and Winter-shaped YAML.
func MakeAdminController(ctx context.Context, startDir, pluginID, name string) (ArtifactResult, error) {
plugin, err := resolvePlugin(startDir, pluginID)
if err != nil {
return ArtifactResult{}, err
}
if err := validateExportedIdent(name); err != nil {
return ArtifactResult{}, fmt.Errorf("build: admin-controller: %w", err)
}
fn := name + "Controller"
if err := rejectDuplicateIdent(plugin.Dir, "controllers", fn); err != nil {
return ArtifactResult{}, err
}
snake := toSnake(name)
rel := filepath.Join("controllers", snake+".go")
if err := rejectDuplicateFile(plugin.Dir, rel); err != nil {
return ArtifactResult{}, err
}
configDir := filepath.ToSlash(filepath.Join("controllers", snake))
fieldsRel := filepath.Join("controllers", snake, "fields.yaml")
columnsRel := filepath.Join("controllers", snake, "columns.yaml")
for _, yamlRel := range []string{fieldsRel, columnsRel} {
if err := rejectDuplicateFile(plugin.Dir, yamlRel); err != nil {
return ArtifactResult{}, err
}
}
src, err := renderStub("admin_controller.go", artifactData{
Ident: name,
Func: fn,
Worker: unexportedIdent(name) + "Admin",
Kind: plugin.ID + "." + snake,
ConfigDir: configDir,
})
if err != nil {
return ArtifactResult{}, err
}
fieldsSrc, err := renderStub("fields.yaml", nil)
if err != nil {
return ArtifactResult{}, err
}
columnsSrc, err := renderStub("columns.yaml", nil)
if err != nil {
return ArtifactResult{}, err
}
var created []string
rollback := true
defer func() {
if rollback {
for _, path := range created {
os.Remove(path)
}
}
}()
goPath := filepath.Join(plugin.Dir, rel)
if err := writeFile(goPath, src); err != nil {
return ArtifactResult{}, err
}
created = append(created, goPath)
fieldsPath := filepath.Join(plugin.Dir, fieldsRel)
if err := writeFile(fieldsPath, fieldsSrc); err != nil {
return ArtifactResult{}, err
}
created = append(created, fieldsPath)
columnsPath := filepath.Join(plugin.Dir, columnsRel)
if err := writeFile(columnsPath, columnsSrc); err != nil {
return ArtifactResult{}, err
}
created = append(created, columnsPath)
if err := finishArtifact(ctx, plugin); err != nil {
return ArtifactResult{}, err
}
rollback = false
return ArtifactResult{PluginDir: plugin.Dir, Files: created, Hint: plugin.Hint}, nil
}
func unexportedIdent(name string) string {
r, size := utf8.DecodeRuneInString(name)
return string(unicode.ToLower(r)) + name[size:]
}
type pluginRef struct {
Dir string
ID string

View File

@@ -37,6 +37,9 @@ func App(ctx context.Context, appDir string, out io.Writer) error {
if err := generate(appDir, m); err != nil {
return err
}
if err := checkModelsLeaf(appDir, m); err != nil {
return err
}
binRel := filepath.Join("bin", m.Binary)
if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil {

View File

@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
@@ -557,6 +558,169 @@ func TestScaffoldCoreArtifacts(t *testing.T) {
}
}
func TestScaffoldAllArtifacts(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
pluginBefore, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
t.Fatal(err)
}
if _, err := MakeModel(ctx, dir, "golem15.demo", "Album", false); err != nil {
t.Fatal(err)
}
if _, err := MakeMigration(ctx, dir, "golem15.demo", "AddAlbumIndex"); err != nil {
t.Fatal(err)
}
if _, err := MakeCommand(ctx, dir, "golem15.demo", "Purge"); err != nil {
t.Fatal(err)
}
job, err := MakeJob(ctx, dir, "golem15.demo", "Reindex")
if err != nil {
t.Fatal(err)
}
if len(job.Files) != 1 {
t.Fatalf("make:job files = %v", job.Files)
}
jobSrc, err := os.ReadFile(job.Files[0])
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"func (ReindexArgs) Kind()",
"func (reindexJob) Work(",
"unexpected args type",
} {
if !bytes.Contains(jobSrc, []byte(want)) {
t.Fatalf("job.go missing %s:\n%s", want, jobSrc)
}
}
if bytes.Contains(bytes.ToLower(jobSrc), []byte("river")) {
t.Fatal("job stub imports River")
}
admin, err := MakeAdminController(ctx, dir, "golem15.demo", "Albums")
if err != nil {
t.Fatal(err)
}
if len(admin.Files) != 3 {
t.Fatalf("make:admin-controller files = %v", admin.Files)
}
adminSrc, err := os.ReadFile(filepath.Join(pluginDir, "controllers", "albums.go"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
`ID() string { return "golem15.demo.albums" }`,
`ModelName() string { return "Albums" }`,
`ConfigDir() string { return "controllers/albums" }`,
} {
if !bytes.Contains(adminSrc, []byte(want)) {
t.Fatalf("albums.go missing %s:\n%s", want, adminSrc)
}
}
for _, rel := range []string{
filepath.Join("controllers", "albums", "fields.yaml"),
filepath.Join("controllers", "albums", "columns.yaml"),
} {
body, err := os.ReadFile(filepath.Join(pluginDir, rel))
if err != nil {
t.Fatal(err)
}
key := "fields:"
if strings.Contains(rel, "columns") {
key = "columns:"
}
if !bytes.Contains(body, []byte(key)) {
t.Fatalf("%s missing %s:\n%s", rel, key, body)
}
}
reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"&models.Album{}",
"updates.CreateAlbums()",
"updates.AddAlbumIndex()",
"console.PurgeCommand()",
"jobs.ReindexJob()",
"controllers.AlbumsController()",
} {
if !bytes.Contains(reg1, []byte(want)) {
t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1)
}
}
if _, err := MakeJob(ctx, dir, "golem15.demo", "Reindex"); err == nil {
t.Fatal("duplicate make:job want error")
}
if err := refreshRegistry(pluginDir); err != nil {
t.Fatal(err)
}
reg2, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(reg1, reg2) {
t.Fatal("repeated generation changed registry.gen.go")
}
pluginAfter, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(pluginBefore, pluginAfter) {
t.Fatal("handwritten plugin.go was rewritten")
}
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)
}
}
func TestModelsLeaf(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
t.Fatal(err)
}
modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
t.Fatal(err)
}
bad := "package models\n\nimport " + strconv.Quote(modPath+"/classes") + "\n"
if err := os.WriteFile(filepath.Join(pluginDir, "models", "bad.go"), []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
err = App(ctx, dir, &buf)
if err == nil {
t.Fatal("want models leaf import error")
}
msg := err.Error()
for _, want := range []string{"golem15.demo", "models/bad.go", modPath + "/classes"} {
if !strings.Contains(msg, want) {
t.Fatalf("leaf error %q missing %s", msg, want)
}
}
}
func assertScaffoldFiles(t *testing.T, pluginDir string) {
t.Helper()
assertWinterPluginShape(t, pluginDir)

81
internal/build/leaf.go Normal file
View File

@@ -0,0 +1,81 @@
package build
import (
"fmt"
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"strings"
)
var modelsSiblingLeaves = []string{
"classes",
"controllers",
"console",
"jobs",
"middleware",
"updates",
}
func checkModelsLeaf(appDir string, m Manifest) error {
for _, p := range m.Plugins {
pluginDir := localModuleDir(appDir, p.Module)
if pluginDir == "" {
pluginDir = filepath.Join(appDir, "plugins", pluginName(p.ID))
}
if !underRoot(appDir, pluginDir) {
continue
}
modelsDir := filepath.Join(pluginDir, "models")
st, err := os.Stat(modelsDir)
if err != nil || !st.IsDir() {
continue
}
module, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
return err
}
if err := inspectModelsImports(p.ID, pluginDir, module, modelsDir); err != nil {
return err
}
}
return nil
}
func inspectModelsImports(pluginID, pluginDir, module, modelsDir string) error {
entries, err := os.ReadDir(modelsDir)
if err != nil {
return fmt.Errorf("build: read %s: %w", modelsDir, err)
}
fset := token.NewFileSet()
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") {
continue
}
path := filepath.Join(modelsDir, name)
file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return fmt.Errorf("build: parse %s: %w", path, err)
}
rel, err := filepath.Rel(pluginDir, path)
if err != nil {
rel = filepath.Join("models", name)
}
for _, spec := range file.Imports {
imp, err := strconv.Unquote(spec.Path.Value)
if err != nil {
continue
}
for _, sib := range modelsSiblingLeaves {
prefix := module + "/" + sib
if imp == prefix || strings.HasPrefix(imp, prefix+"/") {
return fmt.Errorf("build: plugin %s: %s imports %s", pluginID, filepath.ToSlash(rel), imp)
}
}
}
}
return nil
}

View File

@@ -59,3 +59,61 @@ func {{.Func}}() bonfire.Command {
}
}
{{end}}
{{define "job.go"}}// Code generated by summer make. DO NOT EDIT.
package jobs
import (
"context"
"fmt"
"git.golem15.com/golem15/summercms/pact"
)
// {{.Ident}}Args is the typed payload for {{.Kind}}.
type {{.Ident}}Args struct{}
func ({{.Ident}}Args) Kind() string { return {{printf "%q" .Kind}} }
type {{.Worker}} struct{}
func ({{.Worker}}) Work(ctx context.Context, args pact.JobArgs) error {
if _, ok := args.({{.Ident}}Args); !ok {
return fmt.Errorf("jobs: unexpected args type %T", args)
}
return nil
}
// {{.Func}} returns the {{.Kind}} job.
func {{.Func}}() pact.Job { return {{.Worker}}{} }
{{end}}
{{define "admin_controller.go"}}// Code generated by summer make. DO NOT EDIT.
package controllers
import "git.golem15.com/golem15/summercms/pact"
type {{.Worker}} struct{}
func ({{.Worker}}) ID() string { return {{printf "%q" .Kind}} }
func ({{.Worker}}) ModelName() string { return {{printf "%q" .Ident}} }
func ({{.Worker}}) ConfigDir() string { return {{printf "%q" .ConfigDir}} }
// {{.Func}} returns the {{.Kind}} admin controller.
func {{.Func}}() pact.AdminController { return {{.Worker}}{} }
{{end}}
{{define "fields.yaml"}}fields:
id:
label: ID
span: auto
type: number
{{end}}
{{define "columns.yaml"}}columns:
id:
label: ID
searchable: true
{{end}}