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

@@ -207,9 +207,10 @@ func makeCommandCommand() bonfire.Command {
Long: `Writes console/<name>.go with a bonfire.Command named <plugin>:<name> in the
plugin with this ID or the plugin whose directory you are in; the generated
registry adds it to the commands the plugin returns. The generated function
takes no arguments, so a command that needs the database or another service
takes a parameter instead and is returned from the plugin's Commands by hand
(see the porting guide's blog:publish).`,
receives the application the plugin kept from Boot (*backpack.App), which
gives it the config and the services plugins publish. A function with another
parameter, such as the porting guide's withDB for blog:publish, is returned
from the plugin's Commands by hand.`,
Examples: []bonfire.Example{
{Command: "make:command acme.blog Publish", Description: "A blog:publish command in acme.blog"},
{Command: "make:command Publish", Description: "The same from inside plugins/acme/blog"},

View File

@@ -126,12 +126,14 @@ summer make:model Comment
|---------|--------|-------|
| `summer make:model` | `models/<name>.go` and `updates/<timestamp>_create_<table>.go` | The table name is the plugin ID and the plural name in snake case, such as `acme_blog_posts`. `--no-migration` skips the migration. |
| `summer make:migration` | `updates/<timestamp>_<name>.go` | An empty gormigrate migration with up and down steps to fill in. |
| `summer make:command` | `console/<name>.go` | A `bonfire.Command` named `<plugin>:<name>`, such as `blog:publish`. |
| `summer make:command` | `console/<name>.go` | A `bonfire.Command` named `<plugin>:<name>`, such as `blog:publish`, from a function that takes the `*backpack.App` the plugin kept from `Boot`. A scaffolded plugin's `Commands` returns `generatedCommands(p.app)`; when `plugin.go` still calls `generatedCommands()` without the app, the command notes that the function receives `nil`. |
| `summer make:job` | `jobs/<name>.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. |
| `summer make:admin-controller` | `controllers/<name>.go`, `controllers/<name>/config_form.yaml`, `controllers/<name>/config_list.yaml`, `models/<name>/fields.yaml`, `models/<name>/columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. It already requires the permission `<plugin>.access_<name>`, which you declare in the plugin's `Permissions()`, and its `NewRecord` returns `nil` until you return your model, so the screens stay closed until you finish it. |
File names are the snake-case form of the name: `AddPublishedAt` becomes `add_published_at`. Migration file names start with a 14-digit UTC timestamp. Each new migration gets a timestamp after every file already in `updates/`: the current second, or one second past the newest existing migration when that is not earlier. File names therefore sort in creation order, and so does the list in `registry.gen.go`, even for migrations made within one second.
Every file these commands write for you to edit starts with the line `//summer:make <kind>`, such as `//summer:make model` or `//summer:make admin-controller`. Keep it: `registry.gen.go` lists only the files that carry it, or the older `// Code generated by summer make. DO NOT EDIT.` header, which earlier versions wrote and which is still recognised. `registry.gen.go` itself keeps that header, so it is the only file linters treat as generated.
After writing the files, every `make:` command regenerates the plugin's `registry.gen.go`, which lists the plugin's models, migrations, commands, jobs and admin controllers, and runs `go mod tidy` in the plugin. The capability methods of a scaffolded `plugin.go` return those generated lists. If your `plugin.go` was written by hand and does not call them, the command prints a note naming the accessors to add.
Each command prints `Created <path>` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has.

View File

@@ -6,7 +6,7 @@ order: 40
---
# Writing commands
A plugin adds console commands to the application binary the way a WinterCMS plugin calls `registerConsoleCommand`. In SummerCMS a command is a plain `bonfire.Command` value, and the plugin returns its commands from `pact.HasCommands`. `summer make:command acme.blog Publish` generates a starting point in `console/publish.go`.
A plugin adds console commands to the application binary the way a WinterCMS plugin calls `registerConsoleCommand`. In SummerCMS a command is a plain `bonfire.Command` value, and the plugin returns its commands from `pact.HasCommands`. `summer make:command acme.blog Publish` generates a starting point in `console/publish.go`: a `PublishCommand(app *backpack.App) bonfire.Command` function. A command that needs the database, the config or a service another plugin publishes reaches it through `app`, for example with `backpack.App.Lookup`.
## Defining a command
@@ -50,7 +50,7 @@ if err := catalog.Call(context.Background(), "blog:publish", args, os.Stdout); e
Return the commands from the plugin's `Commands` method, which implements `pact.HasCommands`. The generated `main` appends every plugin's commands after the framework's runtime commands. Command names must be unique: when two commands share a name, or a command is named `list` or `help`, `bonfire.NewRoot` fails with `bonfire.ErrDuplicateCommand` naming the command, and the binary does not start. Keep your commands in your plugin's own namespace, such as `blog:`. The global options are reserved as well: a flag named `verbose`, `quiet`, `ansi`, `no-ansi`, `no-interaction`, `env` or `help`, or with the shorthand `v`, `q`, `n` or `h`, makes `bonfire.NewRoot` fail with `bonfire.ErrReservedFlag`.
A scaffolded plugin's `Commands` method returns the generated list of everything in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`.
A scaffolded plugin keeps the application in `Boot`, and its `Commands` method returns `generatedCommands(p.app)`, the generated list of the commands in `console/`, so commands created with `summer make:command` are registered without editing `plugin.go`. The list holds every function in a file that starts with `//summer:make` that returns a `bonfire.Command` and takes no parameter or a single `*backpack.App`; the generated code passes it the application. `Commands` is collected after `Boot`, so the application is set by then. A command function with other parameters is returned from `Commands` by hand. In an older `plugin.go` that calls `generatedCommands()` without the application, the generated functions receive `nil`; `summer make:command` prints a note when it finds that call.
## Output

View File

@@ -1,4 +1,4 @@
// Code generated by summer make. DO NOT EDIT.
//summer:make command
package console

View File

@@ -1,4 +1,4 @@
// Code generated by summer make. DO NOT EDIT.
//summer:make admin-controller
package controllers

View File

@@ -1,4 +1,4 @@
// Code generated by summer make. DO NOT EDIT.
//summer:make model
package models

View File

@@ -72,10 +72,11 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// Commands returns the generated commands plus blog:publish, which needs the
// database and so is built here with the plugin's withDB.
// Commands returns the generated commands, which receive the application
// kept in Boot, plus blog:publish. That one takes the narrower withDB, which
// its tests replace, so it is added here by hand.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(), console.PublishCommand(p.withDB))
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
}
// AdminFS is the admin YAML the controllers read: controllers/posts and

View File

@@ -6,6 +6,7 @@ import (
"git.golem15.com/golem15/summercms/docs/examples/blog/controllers"
"git.golem15.com/golem15/summercms/docs/examples/blog/models"
"git.golem15.com/golem15/summercms/docs/examples/blog/updates"
"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"
@@ -24,7 +25,9 @@ func generatedMigrations() []*gormigrate.Migration {
}
}
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 {
return nil
}

View File

@@ -1,4 +1,4 @@
// Code generated by summer make. DO NOT EDIT.
//summer:make migration
package updates

View File

@@ -1,4 +1,4 @@
// Code generated by summer make. DO NOT EDIT.
//summer:make migration
package updates

View File

@@ -183,10 +183,11 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// Commands returns the generated commands plus blog:publish, which needs the
// database and so is built here with the plugin's withDB.
// Commands returns the generated commands, which receive the application
// kept in Boot, plus blog:publish. That one takes the narrower withDB, which
// its tests replace, so it is added here by hand.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(), console.PublishCommand(p.withDB))
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
}
// AdminFS is the admin YAML the controllers read: controllers/posts and
@@ -575,7 +576,7 @@ class Posts extends Controller
`summer make:admin-controller acme.blog Posts` writes `controllers/posts.go` and four YAML files. In SummerCMS the controller has no actions or views: the framework's generic admin API lists, shows, creates, updates and deletes records, and the admin SPA draws the screens from the YAML. The controller only says which model it serves, where its YAML is and which permission it needs:
```go src=docs/examples/blog/controllers/posts.go
// Code generated by summer make. DO NOT EDIT.
//summer:make admin-controller
package controllers
@@ -726,10 +727,10 @@ class Publish extends Command
}
```
`summer make:command acme.blog Publish` writes `console/publish.go` with a `bonfire.Command` named `blog:publish`. The command needs the database, which only the plugin can reach, so the finished function takes it as a parameter:
`summer make:command acme.blog Publish` writes `console/publish.go` with a `bonfire.Command` named `blog:publish`, built by a function that takes the `*backpack.App` the plugin kept from `Boot`; through it a command reaches the config and the database. This command takes something narrower instead, a `withDB` function its tests can replace without an application:
```go src=docs/examples/blog/console/publish.go
// Code generated by summer make. DO NOT EDIT.
//summer:make command
package console
@@ -779,13 +780,14 @@ the first publication time. It fails when no post has the slug.`,
}
```
Because the function now takes a parameter, the generated accessor no longer lists it; the plugin adds it in `Commands` and passes its `withDB`, which uses the database the server published or, when the command runs from the console, opens one from the application config for the command's duration:
The generated accessor lists command functions that take no parameter or a single `*backpack.App`, and passes them the application. `PublishCommand` takes `withDB` instead, so the accessor leaves it out; the plugin adds it in `Commands` and passes its `withDB`, which uses the database the server published or, when the command runs from the console, opens one from the application config for the command's duration:
```go src=docs/examples/blog/plugin.go#Plugin.Commands
// Commands returns the generated commands plus blog:publish, which needs the
// database and so is built here with the plugin's withDB.
// Commands returns the generated commands, which receive the application
// kept in Boot, plus blog:publish. That one takes the narrower withDB, which
// its tests replace, so it is added here by hand.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(), console.PublishCommand(p.withDB))
return append(generatedCommands(p.app), console.PublishCommand(p.withDB))
}
```
@@ -819,10 +821,10 @@ See [Writing commands](../console/writing-commands.md) for arguments, flags, pro
The `make:` commands write files that compile, not a finished plugin. These are the steps this walkthrough had to do by hand, and the scaffolder behaviour behind them:
- **The generated-code header stays.** Files the `make:` commands write start with `// Code generated by summer make. DO NOT EDIT.`, although you are meant to edit them. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it, so a model, migration, command or admin controller whose header you delete disappears from the accessors the next time you run a `make:` command. Linters also treat these files as generated and skip them.
- **The `//summer:make` line stays.** Files the `make:` commands write for you to edit start with `//summer:make <kind>`, such as `//summer:make model`. Keep the line: the commands rebuild `registry.gen.go` from the files that carry it (or the older `// Code generated by summer make. DO NOT EDIT.` header), so a model, migration, command, job or admin controller whose line you delete disappears from the accessors the next time you run a `make:` command. Only `registry.gen.go` itself is marked as generated, so linters check the files you edit.
- **The admin controller names the model after itself.** `make:admin-controller acme.blog Posts` returns `Posts` from `ModelName` and writes `modelClass: Posts`, and it puts `fields.yaml` and `columns.yaml` under `models/posts/`. Change `ModelName` and both `modelClass` values to the model, `Post`; the YAML can stay where it is, as in this example.
- **The admin controller needs more than the scaffolder writes.** The generated controller implements only `pact.AdminController`. Add `NewRecord` (`pact.AdminRecordSource`) so the admin API has a model to query, and `RequiredPermissions` (`pact.AdminPermissioned`): without it, any signed-in administrator can open the controller. The model needs `Fillable` and `Rules` before the admin API can save it. The plugin needs `AdminFS` (`pact.AdminAssets`) to embed the YAML; without it the application refuses to start once the admin is enabled.
- **A command that needs the application takes it as a parameter.** The function `make:command` writes takes no arguments, which is what the generated accessor looks for, so it cannot reach the database or the config. Give it the dependency as a parameter and return it from `Commands` yourself, as `blog:publish` does.
- **A command with a narrower dependency is added by hand.** The function `make:command` writes receives the `*backpack.App` the plugin kept from `Boot`, and the generated accessor lists such functions. A command that takes something else, such as the `withDB` of `blog:publish`, is left out of the accessor; return it from `Commands` yourself.
## Checklist

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