feat(build): mark scaffolded files with //summer:make and pass the app to generated commands (quick-261028-pq8)

- model, migration, command, job and admin-controller stubs start with
  //summer:make <kind>; registry.gen.go keeps its generated-code header
- the registry scan accepts the directive and the older header
- command functions may take one *backpack.App; generatedCommands takes a
  variadic app, so plugin.go files calling generatedCommands() still compile
- scaffolded plugins keep the app from Boot and return generatedCommands(p.app)
- make:command notes the nil app for an older plugin.go
- docs, help text and the blog example follow
This commit is contained in:
Jakub Zych
2026-10-28 19:04:39 +01:00
parent a95a014b52
commit 70e4b4183c
18 changed files with 355 additions and 61 deletions

View File

@@ -6,6 +6,7 @@ import (
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
@@ -192,7 +193,33 @@ func MakeCommand(ctx context.Context, startDir, pluginID, name string) (Artifact
os.Remove(path)
return ArtifactResult{}, err
}
return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: plugin.Hint}, nil
hint := plugin.Hint
if note := nilAppCommandHint(filepath.Join(plugin.Dir, "plugin.go"), fn); note != "" {
hint = joinHints(hint, note)
}
return ArtifactResult{PluginDir: plugin.Dir, Files: []string{path}, Hint: hint}, nil
}
// zeroArgGeneratedCommands matches a generatedCommands call without
// arguments, the form plugin.go had before commands took the app.
var zeroArgGeneratedCommands = regexp.MustCompile(`generatedCommands\(\s*\)`)
// nilAppCommandHint explains, for a plugin.go that calls generatedCommands()
// without the app, that the command fn receives a nil *backpack.App.
func nilAppCommandHint(pluginGoPath, fn string) string {
body, err := os.ReadFile(pluginGoPath)
if err != nil || !zeroArgGeneratedCommands.Match(body) {
return ""
}
return "note: console." + fn + " receives a nil *backpack.App because plugin.go calls generatedCommands() without the app; " +
"keep the app in Boot (p.app = app) and return generatedCommands(p.app) from Commands"
}
func joinHints(a, b string) string {
if a == "" {
return b
}
return a + "\n" + b
}
// MakeJob writes a typed job wrapped in conga.Job; the plugin never

View File

@@ -489,7 +489,8 @@ func TestScaffoldPluginSmoke(t *testing.T) {
for _, want := range []string{
"generatedModels()",
"generatedMigrations()",
"generatedCommands()",
"generatedCommands(p.app)",
"p.app = app",
"generatedJobs()",
"generatedAdminControllers()",
"//go:embed config",
@@ -503,7 +504,7 @@ func TestScaffoldPluginSmoke(t *testing.T) {
for _, want := range []string{
"func generatedModels()",
"func generatedMigrations()",
"func generatedCommands()",
"func generatedCommands(app ...*backpack.App) []bonfire.Command",
"func generatedJobs()",
"func generatedAdminControllers()",
} {
@@ -655,7 +656,7 @@ func TestScaffoldCoreArtifacts(t *testing.T) {
"&models.Widget{}",
"updates.CreateAlbums()",
"updates.AddWidgetIndex()",
"console.PurgeCommand()",
"console.PurgeCommand(bootApp)",
} {
if !bytes.Contains(reg1, []byte(want)) {
t.Fatalf("registry.gen.go missing %s:\n%s", want, reg1)
@@ -794,6 +795,22 @@ func TestScaffoldAllArtifacts(t *testing.T) {
if len(command.Files) != 1 || command.Files[0] != wantCommand {
t.Fatalf("make:command files = %v, want %s", command.Files, wantCommand)
}
if command.Hint != "" {
t.Fatalf("new plugin printed a make:command hint: %s", command.Hint)
}
cmdSrc, err := os.ReadFile(wantCommand)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(cmdSrc, []byte("func PurgeCommand(app *backpack.App) bonfire.Command")) {
t.Fatalf("purge.go does not take the app:\n%s", cmdSrc)
}
// Report whether the app arrived, so the built binary proves the wiring.
reported := bytes.Replace(cmdSrc, []byte(`out.Printf("demo:purge" + "\n")`), []byte(`out.Printf("demo:purge app=%t\n", app != nil)`), 1)
if bytes.Equal(reported, cmdSrc) {
t.Fatalf("could not patch purge.go:\n%s", cmdSrc)
}
write(t, wantCommand, string(reported))
job, err := MakeJob(ctx, dir, "golem15.demo", "Reindex")
if err != nil {
@@ -877,16 +894,38 @@ func TestScaffoldAllArtifacts(t *testing.T) {
}
}
for path, kind := range map[string]string{
wantModel: "model",
model.Files[1]: "migration",
wantCommand: "command",
wantJob: "job",
wantAdmin[0]: "admin-controller",
} {
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if want := "//summer:make " + kind + "\n\npackage "; !bytes.HasPrefix(body, []byte(want)) {
t.Fatalf("%s does not start with %q:\n%s", path, want, body)
}
if bytes.Contains(body, []byte("DO NOT EDIT")) {
t.Fatalf("%s still says DO NOT EDIT:\n%s", path, body)
}
}
reg1, err := os.ReadFile(filepath.Join(pluginDir, "registry.gen.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(reg1, []byte("// Code generated by summer make. DO NOT EDIT.\n")) {
t.Fatalf("registry.gen.go lost its generated-code header:\n%s", reg1)
}
for _, want := range []string{
"&models.Album{}",
"&models.Widget{}",
"updates.CreateAlbums()",
"updates.AddAlbumIndex()",
"console.PurgeCommand()",
"console.PurgeCommand(bootApp)",
"jobs.ReindexJob()",
"controllers.AlbumsController()",
} {
@@ -922,6 +961,11 @@ func TestScaffoldAllArtifacts(t *testing.T) {
if err := App(ctx, dir, &buf); err != nil {
t.Fatalf("app build: %v\n%s", err, buf.String())
}
run := exec.CommandContext(ctx, filepath.Join(dir, "bin", "hello"), "demo:purge")
run.Dir = dir
if out, err := run.CombinedOutput(); err != nil || !bytes.Contains(out, []byte("demo:purge app=true")) {
t.Fatalf("built app demo:purge: %v\n%s", err, out)
}
if out, err := execGo(t, dir, "build", "./...").CombinedOutput(); err != nil {
t.Fatalf("copied workspace go build: %v\n%s", err, out)
}
@@ -1518,3 +1562,137 @@ func TestLocatePluginLegacyLayout(t *testing.T) {
t.Fatalf("locatePluginDir with both layouts = %q, %v; want %s", got, err, vendorDir)
}
}
// TestRegistryScanMarkersAndAppCommands: registry.gen.go lists files that
// carry the //summer:make directive or the older generated-code header, and
// a command function may take exactly one *backpack.App.
func TestRegistryScanMarkersAndAppCommands(t *testing.T) {
dir := t.TempDir()
const imports = "import (\n\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n\t\"git.golem15.com/golem15/summercms/modules/bonfire\"\n\t\"gorm.io/gorm\"\n)\n\n" +
"var (\n\t_ backpack.App\n\t_ gorm.DB\n)\n\n"
files := map[string]string{
"legacy.go": "// Code generated by summer make. DO NOT EDIT.\n\npackage console\n\n" + imports + "func LegacyCommand() bonfire.Command { return bonfire.Command{} }\n",
"unmarked.go": "package console\n\n" + imports + "func UnmarkedCommand() bonfire.Command { return bonfire.Command{} }\n",
"lookalike.go": "//summer:makefile command\n\npackage console\n\n" + imports + "func LookalikeCommand() bonfire.Command { return bonfire.Command{} }\n",
"bare.go": "//summer:make\n\npackage console\n\n" + imports + "func BareCommand() bonfire.Command { return bonfire.Command{} }\n",
"named.go": "//summer:make command\n\npackage console\n\n" + imports + "func NamedCommand(app *backpack.App) bonfire.Command { return bonfire.Command{} }\n",
"unnamed.go": "//summer:make command\n\npackage console\n\n" + imports + "func UnnamedCommand(*backpack.App) bonfire.Command { return bonfire.Command{} }\n",
"value.go": "//summer:make command\n\npackage console\n\n" + imports + "func ValueCommand(app backpack.App) bonfire.Command { return bonfire.Command{} }\n",
"two.go": "//summer:make command\n\npackage console\n\n" + imports + "func TwoCommand(app *backpack.App, n int) bonfire.Command { return bonfire.Command{} }\n",
"twonames.go": "//summer:make command\n\npackage console\n\n" + imports + "func TwoNamesCommand(a, b *backpack.App) bonfire.Command { return bonfire.Command{} }\n",
"other.go": "//summer:make command\n\npackage console\n\n" + imports + "func OtherCommand(db *gorm.DB) bonfire.Command { return bonfire.Command{} }\n",
}
for name, body := range files {
write(t, filepath.Join(dir, name), body)
}
refs, err := scanGeneratedIdents(dir, "console", scanCommandFuncs)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, ref := range refs {
got[ref.Ident] = ref.TakesApp
}
want := map[string]bool{
"BareCommand": false,
"LegacyCommand": false,
"NamedCommand": true,
"UnnamedCommand": true,
}
if len(got) != len(want) {
t.Fatalf("refs = %+v, want %v", refs, want)
}
for ident, takesApp := range want {
if v, ok := got[ident]; !ok || v != takesApp {
t.Fatalf("refs = %+v, want %s TakesApp=%t", refs, ident, takesApp)
}
}
// Other kinds still take no parameters at all.
jobsDir := t.TempDir()
write(t, filepath.Join(jobsDir, "job.go"), "//summer:make job\n\npackage jobs\n\nimport (\n\t\"git.golem15.com/golem15/summercms/modules/backpack\"\n\t\"git.golem15.com/golem15/summercms/modules/pact\"\n)\n\n"+
"func AppJob(app *backpack.App) pact.Job { return nil }\n\nfunc PlainJob() pact.Job { return nil }\n")
jobRefs, err := scanGeneratedIdents(jobsDir, "jobs", scanJobFuncs)
if err != nil {
t.Fatal(err)
}
if len(jobRefs) != 1 || jobRefs[0].Ident != "PlainJob" {
t.Fatalf("job refs = %+v, want only PlainJob", jobRefs)
}
sortRefs(refs)
data := registryData{Package: "demo", Module: "example.com/app/plugins/acme/demo", Commands: refs}
data.prepare()
src, err := renderStub("registry.go", data)
if err != nil {
t.Fatal(err)
}
for _, line := range []string{
"func generatedCommands(app ...*backpack.App) []bonfire.Command {",
"var bootApp *backpack.App",
"console.BareCommand(),",
"console.LegacyCommand(),",
"console.NamedCommand(bootApp),",
"console.UnnamedCommand(bootApp),",
} {
if !bytes.Contains(src, []byte(line)) {
t.Fatalf("registry.gen.go missing %s:\n%s", line, src)
}
}
// Without app-taking commands the local is not declared.
data = registryData{Package: "demo", Module: "example.com/app/plugins/acme/demo", Commands: []registryRef{{Package: "console", Ident: "LegacyCommand"}}}
data.prepare()
src, err = renderStub("registry.go", data)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(src, []byte("bootApp")) {
t.Fatalf("registry.gen.go declares bootApp without app-taking commands:\n%s", src)
}
}
// TestMakeCommandLegacyPluginGo: a plugin.go in the older shape (no app
// kept, generatedCommands() without arguments) still compiles after
// make:command, and the command prints how to pass the app.
func TestMakeCommandLegacyPluginGo(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
pluginGo := filepath.Join(pluginDir, "plugin.go")
src, err := os.ReadFile(pluginGo)
if err != nil {
t.Fatal(err)
}
old := string(src)
for _, r := range []struct{ from, to string }{
{"type Plugin struct {\n\t// app is the application, kept from Boot for the generated commands.\n\tapp *backpack.App\n}", "type Plugin struct{}"},
{"func (p *Plugin) Boot(app *backpack.App) error {\n\tp.app = app\n\treturn nil\n}", "func (p *Plugin) Boot(*backpack.App) error { return nil }"},
{"generatedCommands(p.app)", "generatedCommands( )"},
} {
if !strings.Contains(old, r.from) {
t.Fatalf("plugin.go lacks %q:\n%s", r.from, old)
}
old = strings.Replace(old, r.from, r.to, 1)
}
write(t, pluginGo, old)
res, err := MakeCommand(ctx, dir, "golem15.demo", "Purge")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"console.PurgeCommand receives a nil *backpack.App", "generatedCommands(p.app)"} {
if !strings.Contains(res.Hint, want) {
t.Fatalf("hint %q lacks %q", res.Hint, want)
}
}
if out, err := execGo(t, pluginDir, "build", "./...").CombinedOutput(); err != nil {
t.Fatalf("legacy plugin.go does not build: %v\n%s", err, out)
}
if out, err := execGo(t, pluginDir, "vet", "./...").CombinedOutput(); err != nil {
t.Fatalf("legacy plugin.go vet: %v\n%s", err, out)
}
}

View File

@@ -13,8 +13,14 @@ import (
)
const (
registryFileName = "registry.gen.go"
generatedMark = "Code generated by summer make"
registryFileName = "registry.gen.go"
// generatedMark is the header registry.gen.go carries. Files scaffolded
// before the scaffoldDirective existed carry it too and stay listed.
generatedMark = "Code generated by summer make"
// scaffoldDirective, followed by a space and the artifact kind, is the
// first line of every file a make: command writes for the developer to
// edit. registry.gen.go lists the files that carry it.
scaffoldDirective = "//summer:make"
)
type identScanKind int
@@ -31,6 +37,8 @@ type registryRef struct {
Package string
Ident string
SortKey string
// TakesApp marks a command function with one *backpack.App parameter.
TakesApp bool
}
type registryData struct {
@@ -42,6 +50,8 @@ type registryData struct {
Commands []registryRef
Jobs []registryRef
AdminControllers []registryRef
// AppCommands is set when at least one command takes the app.
AppCommands bool
}
func emptyRegistry(pkg, module string) registryData {
@@ -52,6 +62,12 @@ func emptyRegistry(pkg, module string) registryData {
func (d *registryData) prepare() {
d.Imports = uniqueLeafImports(d.Module, d.Models, d.Migrations, d.Commands, d.Jobs, d.AdminControllers)
d.AppCommands = false
for _, ref := range d.Commands {
if ref.TakesApp {
d.AppCommands = true
}
}
}
func uniqueLeafImports(module string, groups ...[]registryRef) []string {
@@ -142,7 +158,7 @@ func scanGeneratedIdents(dir, pkg string, kind identScanKind) ([]registryRef, er
if err != nil {
return nil, fmt.Errorf("build: parse %s: %w", path, err)
}
if !isGeneratedFile(file) {
if !isScaffoldedFile(file) {
continue
}
switch kind {
@@ -150,7 +166,7 @@ func scanGeneratedIdents(dir, pkg string, kind identScanKind) ([]registryRef, er
refs = append(refs, structRefs(pkg, name, file)...)
default:
want := resultTypeFor(kind)
refs = append(refs, funcRefs(pkg, name, file, want)...)
refs = append(refs, funcRefs(pkg, name, file, want, kind == scanCommandFuncs)...)
}
}
return refs, nil
@@ -171,9 +187,15 @@ func resultTypeFor(kind identScanKind) string {
}
}
func isGeneratedFile(file *ast.File) bool {
// isScaffoldedFile reports whether file carries the //summer:make directive
// (alone or followed by a space and the kind) or the older generated-code
// header.
func isScaffoldedFile(file *ast.File) bool {
for _, cg := range file.Comments {
for _, c := range cg.List {
if c.Text == scaffoldDirective || strings.HasPrefix(c.Text, scaffoldDirective+" ") {
return true
}
if strings.Contains(c.Text, generatedMark) {
return true
}
@@ -204,15 +226,22 @@ func structRefs(pkg, filename string, file *ast.File) []registryRef {
return refs
}
func funcRefs(pkg, filename string, file *ast.File, want string) []registryRef {
// funcRefs lists the exported functions of file that return want and take
// no parameters; with allowApp, also those whose only parameter is a
// *backpack.App.
func funcRefs(pkg, filename string, file *ast.File, want string, allowApp bool) []registryRef {
var refs []registryRef
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || fn.Name == nil || !fn.Name.IsExported() || fn.Type == nil {
continue
}
takesApp := false
if fn.Type.Params != nil && len(fn.Type.Params.List) > 0 {
continue
if !allowApp || !isSingleAppParam(fn.Type.Params) {
continue
}
takesApp = true
}
if fn.Type.Results == nil || len(fn.Type.Results.List) != 1 {
continue
@@ -221,14 +250,33 @@ func funcRefs(pkg, filename string, file *ast.File, want string) []registryRef {
continue
}
refs = append(refs, registryRef{
Package: pkg,
Ident: fn.Name.Name,
SortKey: strings.TrimSuffix(filename, ".go"),
Package: pkg,
Ident: fn.Name.Name,
SortKey: strings.TrimSuffix(filename, ".go"),
TakesApp: takesApp,
})
}
return refs
}
// isSingleAppParam reports a parameter list of exactly one *backpack.App,
// named or not. astTypeName drops the star, so the pointer is checked here.
func isSingleAppParam(params *ast.FieldList) bool {
if len(params.List) != 1 || len(params.List[0].Names) > 1 {
return false
}
star, ok := params.List[0].Type.(*ast.StarExpr)
if !ok {
return false
}
sel, ok := star.X.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return false
}
pkg, ok := sel.X.(*ast.Ident)
return ok && pkg.Name == "backpack" && sel.Sel.Name == "App"
}
func astTypeName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.StarExpr:

View File

@@ -1,4 +1,4 @@
{{define "model.go"}}// Code generated by summer make. DO NOT EDIT.
{{define "model.go"}}//summer:make model
package models
@@ -14,7 +14,7 @@ type {{.Ident}} struct {
func ({{.Ident}}) TableName() string { return {{printf "%q" .Table}} }
{{end}}
{{define "migration.go"}}// Code generated by summer make. DO NOT EDIT.
{{define "migration.go"}}//summer:make migration
package updates
@@ -37,18 +37,21 @@ func {{.Func}}() *gormigrate.Migration {
}
{{end}}
{{define "command.go"}}// Code generated by summer make. DO NOT EDIT.
{{define "command.go"}}//summer:make command
package console
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// {{.Func}} returns the {{.CommandName}} console command.
func {{.Func}}() bonfire.Command {
// {{.Func}} returns the {{.CommandName}} console command. app is the
// application the plugin kept from Boot: it gives the command the config and
// the services plugins publish (app.Lookup).
func {{.Func}}(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: {{printf "%q" .CommandName}},
Description: {{printf "%q" .Description}},
@@ -60,7 +63,7 @@ func {{.Func}}() bonfire.Command {
}
{{end}}
{{define "job.go"}}// Code generated by summer make. DO NOT EDIT.
{{define "job.go"}}//summer:make job
package jobs
@@ -84,7 +87,7 @@ func {{.Func}}() pact.Job {
}
{{end}}
{{define "admin_controller.go"}}// Code generated by summer make. DO NOT EDIT.
{{define "admin_controller.go"}}//summer:make admin-controller
package controllers

View File

@@ -32,13 +32,20 @@ var langFS embed.FS
var mailFS embed.FS
// Plugin is the {{.ID}} plugin.
type Plugin struct{}
type Plugin struct {
// app is the application, kept from Boot for the generated commands.
app *backpack.App
}
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) Boot(app *backpack.App) error {
p.app = app
return nil
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
@@ -47,10 +54,13 @@ 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) Models() []any { return generatedModels() }
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
// Commands is collected after Boot, so p.app is set.
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands(p.app) }
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}

View File

@@ -3,6 +3,7 @@
package {{.Package}}
import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/go-gormigrate/gormigrate/v2"
@@ -35,11 +36,19 @@ func generatedMigrations() []*gormigrate.Migration {
{{- end}}
}
func generatedCommands() []bonfire.Command {
// generatedCommands returns the plugin's console commands. Commands that
// take a *backpack.App receive the first app passed in, or nil when none is.
func generatedCommands(app ...*backpack.App) []bonfire.Command {
{{- if .Commands}}
{{- if .AppCommands}}
var bootApp *backpack.App
if len(app) > 0 {
bootApp = app[0]
}
{{- end}}
return []bonfire.Command{
{{- range .Commands}}
{{.Package}}.{{.Ident}}(),
{{.Package}}.{{.Ident}}({{if .TakesApp}}bootApp{{end}}),
{{- end}}
}
{{- else}}

View File

@@ -32,13 +32,20 @@ var langFS embed.FS
var mailFS embed.FS
// Plugin is the golem15.demo plugin.
type Plugin struct{}
type Plugin struct {
// app is the application, kept from Boot for the generated commands.
app *backpack.App
}
func (p *Plugin) ID() string { return "golem15.demo" }
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) Boot(app *backpack.App) error {
p.app = app
return nil
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) LangFS() fs.FS { return langFS }
@@ -49,8 +56,11 @@ 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() }
// Commands is collected after Boot, so p.app is set.
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands(p.app) }
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}