From 70e4b4183c1aa3e888caf20724ef0be46e5543a9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 19:04:39 +0100 Subject: [PATCH] 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 ; 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 --- cmd/summer/main.go | 7 +- docs/console/scaffolding.md | 4 +- docs/console/writing-commands.md | 4 +- docs/examples/blog/console/publish.go | 2 +- docs/examples/blog/controllers/posts.go | 2 +- docs/examples/blog/models/post.go | 2 +- docs/examples/blog/plugin.go | 7 +- docs/examples/blog/registry.gen.go | 5 +- .../20260101000000_create_acme_blog_posts.go | 2 +- .../20260101000100_add_published_at.go | 2 +- docs/setup/porting-a-plugin.md | 26 +-- internal/build/artifact.go | 29 ++- internal/build/build_test.go | 186 +++++++++++++++++- internal/build/registry.go | 68 ++++++- internal/build/stubs/artifacts.tmpl | 17 +- internal/build/stubs/plugin.tmpl | 22 ++- internal/build/stubs/registry.tmpl | 13 +- .../plugins__golem15__demo__plugin.go.golden | 18 +- 18 files changed, 355 insertions(+), 61 deletions(-) diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 2f17a272..936f35de 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -207,9 +207,10 @@ func makeCommandCommand() bonfire.Command { Long: `Writes console/.go with a bonfire.Command named : 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"}, diff --git a/docs/console/scaffolding.md b/docs/console/scaffolding.md index 719dd494..fe8478f8 100644 --- a/docs/console/scaffolding.md +++ b/docs/console/scaffolding.md @@ -126,12 +126,14 @@ summer make:model Comment |---------|--------|-------| | `summer make:model` | `models/.go` and `updates/_create_.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/_.go` | An empty gormigrate migration with up and down steps to fill in. | -| `summer make:command` | `console/.go` | A `bonfire.Command` named `:`, such as `blog:publish`. | +| `summer make:command` | `console/.go` | A `bonfire.Command` named `:`, 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/.go` | A typed job built with `conga.Job`; the plugin never imports the queue library. | | `summer make:admin-controller` | `controllers/.go`, `controllers//config_form.yaml`, `controllers//config_list.yaml`, `models//fields.yaml`, `models//columns.yaml` | A `pact.AdminController` with WinterCMS-shaped form and list configuration. It already requires the permission `.access_`, 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 `, 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 ` for every file it writes. A command refuses to overwrite an existing file or to declare a name the package already has. diff --git a/docs/console/writing-commands.md b/docs/console/writing-commands.md index c9fa4558..46043fa8 100644 --- a/docs/console/writing-commands.md +++ b/docs/console/writing-commands.md @@ -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 diff --git a/docs/examples/blog/console/publish.go b/docs/examples/blog/console/publish.go index 9aa38e0c..76f2c78e 100644 --- a/docs/examples/blog/console/publish.go +++ b/docs/examples/blog/console/publish.go @@ -1,4 +1,4 @@ -// Code generated by summer make. DO NOT EDIT. +//summer:make command package console diff --git a/docs/examples/blog/controllers/posts.go b/docs/examples/blog/controllers/posts.go index c44e54ab..68b10d1d 100644 --- a/docs/examples/blog/controllers/posts.go +++ b/docs/examples/blog/controllers/posts.go @@ -1,4 +1,4 @@ -// Code generated by summer make. DO NOT EDIT. +//summer:make admin-controller package controllers diff --git a/docs/examples/blog/models/post.go b/docs/examples/blog/models/post.go index 550ed343..585fe417 100644 --- a/docs/examples/blog/models/post.go +++ b/docs/examples/blog/models/post.go @@ -1,4 +1,4 @@ -// Code generated by summer make. DO NOT EDIT. +//summer:make model package models diff --git a/docs/examples/blog/plugin.go b/docs/examples/blog/plugin.go index 849a71e9..67aca74f 100644 --- a/docs/examples/blog/plugin.go +++ b/docs/examples/blog/plugin.go @@ -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 diff --git a/docs/examples/blog/registry.gen.go b/docs/examples/blog/registry.gen.go index 808e8971..08189bb2 100644 --- a/docs/examples/blog/registry.gen.go +++ b/docs/examples/blog/registry.gen.go @@ -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 } diff --git a/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go b/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go index aea580ba..6c167473 100644 --- a/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go +++ b/docs/examples/blog/updates/20260101000000_create_acme_blog_posts.go @@ -1,4 +1,4 @@ -// Code generated by summer make. DO NOT EDIT. +//summer:make migration package updates diff --git a/docs/examples/blog/updates/20260101000100_add_published_at.go b/docs/examples/blog/updates/20260101000100_add_published_at.go index 0a650280..8ff9b96e 100644 --- a/docs/examples/blog/updates/20260101000100_add_published_at.go +++ b/docs/examples/blog/updates/20260101000100_add_published_at.go @@ -1,4 +1,4 @@ -// Code generated by summer make. DO NOT EDIT. +//summer:make migration package updates diff --git a/docs/setup/porting-a-plugin.md b/docs/setup/porting-a-plugin.md index 5eb652fc..7c435c55 100644 --- a/docs/setup/porting-a-plugin.md +++ b/docs/setup/porting-a-plugin.md @@ -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 `, 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 diff --git a/internal/build/artifact.go b/internal/build/artifact.go index 5aff5f06..79025b85 100644 --- a/internal/build/artifact.go +++ b/internal/build/artifact.go @@ -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 diff --git a/internal/build/build_test.go b/internal/build/build_test.go index d03b1d31..e4c31408 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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) + } +} diff --git a/internal/build/registry.go b/internal/build/registry.go index 5a726248..51db0724 100644 --- a/internal/build/registry.go +++ b/internal/build/registry.go @@ -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: diff --git a/internal/build/stubs/artifacts.tmpl b/internal/build/stubs/artifacts.tmpl index 817e838e..60fbbb44 100644 --- a/internal/build/stubs/artifacts.tmpl +++ b/internal/build/stubs/artifacts.tmpl @@ -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 diff --git a/internal/build/stubs/plugin.tmpl b/internal/build/stubs/plugin.tmpl index 7fd05a4d..30869ffe 100644 --- a/internal/build/stubs/plugin.tmpl +++ b/internal/build/stubs/plugin.tmpl @@ -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() } diff --git a/internal/build/stubs/registry.tmpl b/internal/build/stubs/registry.tmpl index 482157be..c4d8a571 100644 --- a/internal/build/stubs/registry.tmpl +++ b/internal/build/stubs/registry.tmpl @@ -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}} diff --git a/internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden b/internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden index fb0f51ac..61a540b6 100644 --- a/internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden +++ b/internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden @@ -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() }