fix(build): scaffold plugins under plugins/<vendor>/<name> (quick-261028-pq8)

- make:plugin writes plugins/<vendor>/<name> with module
  <app>/plugins/<vendor>/<name>; the package name stays <name>
- make:* and the models-leaf check look in summer.yaml, then
  plugins/<vendor>/<name>, then the older plugins/<name>
- summer new puts its starter plugin at plugins/golem15/<name>
- refuse a vendor directory that is a legacy plugin module and a second
  copy of a plugin that already sits in plugins/<name>
- help text and docs, with an upgrading note: moving is optional
This commit is contained in:
Jakub Zych
2026-10-28 18:55:46 +01:00
parent a941ab2394
commit a95a014b52
26 changed files with 247 additions and 86 deletions

View File

@@ -96,14 +96,14 @@ func makePluginCommand() bonfire.Command {
return bonfire.Command{
Name: "make:plugin",
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
Long: `Creates plugins/<plugin> in the current application: a Go module with plugin.go
Long: `Creates plugins/<vendor>/<plugin> in the current application: a Go module with plugin.go
(the plugin ID, Register and Boot) and go.mod, with a replace for a local
framework checkout when the application uses one. Register it with plugin:add
so the next summer build compiles it in. Run it inside an application; the
directory must not exist yet.`,
Examples: []bonfire.Example{
{Command: "make:plugin acme.blog", Description: "Create plugins/blog with the ID acme.blog"},
{Command: "plugin:add plugins/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
{Command: "make:plugin acme.blog", Description: "Create plugins/acme/blog with the ID acme.blog"},
{Command: "plugin:add plugins/acme/blog", Description: "Then register it in summer.yaml, go.mod and go.work"},
},
Args: []bonfire.Arg{{
Name: "id",
@@ -142,7 +142,7 @@ plugin picks them up on the next build. Existing files and names are never
overwritten.`,
Examples: []bonfire.Example{
{Command: "make:model acme.blog Post", Description: "A Post model and its create-table migration"},
{Command: "make:model Post --no-migration", Description: "Inside plugins/blog: only the model"},
{Command: "make:model Post --no-migration", Description: "Inside plugins/acme/blog: only the model"},
},
Args: makeArtifactArgs(),
Flags: []bonfire.Flag{{
@@ -179,7 +179,7 @@ created them. registry.gen.go adds it to the plugin's migrations; run migrate
to apply it.`,
Examples: []bonfire.Example{
{Command: "make:migration acme.blog AddSlugToPosts", Description: "An empty migration in acme.blog"},
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/blog"},
{Command: "make:migration AddSlugToPosts", Description: "The same from inside plugins/acme/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
@@ -212,7 +212,7 @@ takes a parameter instead and is returned from the plugin's Commands by hand
(see the porting guide's blog:publish).`,
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/blog"},
{Command: "make:command Publish", Description: "The same from inside plugins/acme/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
@@ -243,7 +243,7 @@ generated registry adds it to the jobs the plugin returns. Dispatch it with
conga.Manager.Dispatch; queue:work or serve runs it.`,
Examples: []bonfire.Example{
{Command: "make:job acme.blog SendDigest", Description: "A SendDigest job in acme.blog"},
{Command: "make:job SendDigest", Description: "The same from inside plugins/blog"},
{Command: "make:job SendDigest", Description: "The same from inside plugins/acme/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
@@ -276,7 +276,7 @@ Permissions(), and its NewRecord returns nil until you return your model, so
the screens stay closed until you finish it.`,
Examples: []bonfire.Example{
{Command: "make:admin-controller acme.blog Posts", Description: "A Posts controller in acme.blog"},
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/blog"},
{Command: "make:admin-controller Posts", Description: "The same from inside plugins/acme/blog"},
},
Args: makeArtifactArgs(),
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {

View File

@@ -118,10 +118,10 @@ func TestMakeCommandsViaCLI(t *testing.T) {
}
out := runSummer("make:plugin", "golem15.demo")
if !strings.Contains(out, filepath.Join("plugins", "demo")) {
if !strings.Contains(out, filepath.Join("plugins", "golem15", "demo")) {
t.Fatalf("make:plugin output missing plugin dir:\n%s", out)
}
pluginDir := filepath.Join(dir, "plugins", "demo")
pluginDir := filepath.Join(dir, "plugins", "golem15", "demo")
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.go")); err != nil {
t.Fatal(err)
}
@@ -193,7 +193,7 @@ func TestNewCommandViaCLI(t *testing.T) {
t.Fatalf("new demo --no-git: %v\n%s", err, out)
}
app := filepath.Join(parent, "sm-demo-app")
for _, rel := range []string{"summer.yaml", "main.go", "plugins/demo/routes.go"} {
for _, rel := range []string{"summer.yaml", "main.go", "plugins/golem15/demo/routes.go"} {
if _, err := os.Stat(filepath.Join(app, rel)); err != nil {
t.Fatalf("%s missing: %v", rel, err)
}

View File

@@ -76,9 +76,9 @@ module: git.golem15.com/acme/acme
binary: acme
plugins:
- id: acme.user
module: git.golem15.com/acme/acme/plugins/user
module: git.golem15.com/acme/acme/plugins/acme/user
- id: acme.blog
module: git.golem15.com/acme/acme/plugins/blog
module: git.golem15.com/acme/acme/plugins/acme/blog
build:
tags:
- summercms_s3
@@ -90,17 +90,17 @@ build:
## Local plugins in a workspace
A plugin you develop inside the application lives in `plugins/<name>` as its own module. `summer make:plugin acme.blog` creates it there, and `summer plugin:add plugins/blog` registers it:
A plugin you develop inside the application lives in `plugins/<vendor>/<name>` as its own module. `summer make:plugin acme.blog` creates it in `plugins/acme/blog`, and `summer plugin:add plugins/acme/blog` registers it:
- it adds the plugin to `summer.yaml`;
- it adds a `require` and a `replace` pointing at the local directory to the application's `go.mod`;
- it adds the plugin directory to the nearest `go.work` as a path relative to that file, such as `./plugins/blog`, creating a `go.work` in the application root when there is none.
- it adds the plugin directory to the nearest `go.work` as a path relative to that file, such as `./plugins/acme/blog`, creating a `go.work` in the application root when there is none.
With a `go.work` in place, `go build`, `go test` and your editor see every local plugin module together. `summer build` uses the nearest `go.work` it finds; without one it builds in module mode.
```sh
summer make:plugin acme.blog
summer plugin:add plugins/blog
summer plugin:add plugins/acme/blog
summer build
```

View File

@@ -52,7 +52,7 @@ The application gets these files:
| `compose.yaml` | PostgreSQL 16 on `127.0.0.1:54320`, with credentials matching `.env`. |
| `.gitignore` | `bin/`, `tmp/`, `storage/`, `.env` and `go.work.sum`. |
| `CLAUDE.md`, `AGENTS.md` | Instructions for AI coding agents: the commands, the layout, the admin, the links to the documentation for agents and the naming convention. `AGENTS.md` is a symlink to `CLAUDE.md`. |
| `plugins/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
| `plugins/golem15/<name>` | The starter plugin `golem15.<name>`, created by the same code as `summer make:plugin` and registered by the same code as `summer plugin:add`. Its `routes.go` answers `GET /` with a JSON greeting. |
| `main.go`, `plugins.gen.go` | Generated from `summer.yaml`, as `summer build` does. |
Unless you pass `--no-git`, `summer new` initialises a git repository on `main` and commits everything except `.env`. When git has no user name or email configured, that first commit uses the author `SummerCMS <summer@localhost>` and prints a warning; your git configuration is not changed. Finally it prints the next steps to run inside `sm-acme-app`:
@@ -79,15 +79,30 @@ Golem15 names repositories by what they hold: `sm-<name>-app` for an application
## Creating a plugin
`summer make:plugin` takes a plugin ID in `vendor.plugin` form and creates the plugin module in `plugins/<plugin>` of the current application, with the directory layout described in [Plugin registration](../plugins/registration.md):
`summer make:plugin` takes a plugin ID in `vendor.plugin` form and creates the plugin module in `plugins/<vendor>/<plugin>` of the current application, with the directory layout described in [Plugin registration](../plugins/registration.md). `acme.blog` becomes `plugins/acme/blog` with the module path `<application module>/plugins/acme/blog` and `package blog`, so `acme.blog` and `pixelpixel.blog` can live in one application:
```sh
summer make:plugin acme.blog
summer plugin:add plugins/blog
summer plugin:add plugins/acme/blog
```
`summer plugin:add` then registers the local module: it adds the plugin to `summer.yaml`, adds a `require` and a local `replace` to the application's `go.mod`, and adds the directory to `go.work`. The next `summer build` compiles the plugin in.
### Upgrading: plugins without a vendor directory
Before this layout, `summer make:plugin` wrote `plugins/<plugin>` without the vendor, and `summer new` put its starter plugin there. Such a plugin keeps working: `summer build` follows `summer.yaml` and the `replace` lines of the application's `go.mod`, and the `make:` commands look for a plugin in `summer.yaml` first, then in `plugins/<vendor>/<plugin>`, then in `plugins/<plugin>`.
Moving it is optional. To move `acme.blog` from `plugins/blog` to `plugins/acme/blog`:
```sh
mkdir -p plugins/acme
mv plugins/blog plugins/acme/blog
go work edit -dropuse ./plugins/blog
summer plugin:add plugins/acme/blog
```
`summer plugin:add` points the application's `replace` and `go.work` at the new directory; the module path and the `summer.yaml` entry stay as they were. If the plugin's own `go.mod` has a relative `replace` for the framework (or another local module), add one `../` to it, for example `../../../summercms` becomes `../../../../summercms`. Then run `summer build`.
## Generating plugin parts
The other `make:` commands add one artifact to an existing plugin. Each takes the plugin ID and an exported Go name:
@@ -103,7 +118,7 @@ summer make:admin-controller acme.blog Posts
When you run a command inside a plugin directory, leave the ID out and pass only the name. The tool finds the plugin from the nearest `plugin.go` above the current directory:
```sh
cd plugins/blog
cd plugins/acme/blog
summer make:model Comment
```

View File

@@ -46,7 +46,7 @@ func TestScaffoldLayout(t *testing.T) {
t.Fatalf("MakeCommand: %v", err)
}
scaffolded := fileSet(t, filepath.Join(app, "plugins", "blog"), func(rel string) bool {
scaffolded := fileSet(t, filepath.Join(app, "plugins", "acme", "blog"), func(rel string) bool {
return rel != "go.mod" && rel != "go.sum"
})
walkthrough := fileSet(t, ".", func(rel string) bool {

View File

@@ -12,7 +12,7 @@ Plugins are the foundation of every SummerCMS application. A plugin adds models,
Every plugin has an ID in `vendor.plugin` form: two lower-case parts, each starting with a letter and containing only letters and digits, such as `acme.blog`. The ID is how the manifest lists the plugin, how other plugins require it and how its configuration is namespaced (`acme.blog.posts_per_page`). WinterCMS writes the same identifier as `Acme.Blog`; in SummerCMS it is always lower case.
The scaffolder derives the package and directory name from the second part, so `summer make:plugin acme.blog` creates `plugins/blog` with `package blog`.
The scaffolder puts the plugin in a directory per vendor and names the package after the second part, so `summer make:plugin acme.blog` creates `plugins/acme/blog` with `package blog`, and `acme.blog` and `pixelpixel.blog` can sit side by side.
## The plugin type
@@ -104,7 +104,7 @@ Configuration defaults, translations and mail templates ship inside the binary t
`summer make:plugin acme.blog` writes a plugin that compiles and follows the WinterCMS directory layout, with each directory as a Go subpackage:
```text
plugins/blog/
plugins/acme/blog/
├── go.mod the plugin module, requiring the framework
├── plugin.go the Plugin type, its capabilities and init registration
├── routes.go the Routes method

View File

@@ -23,7 +23,7 @@ Tests that need Docker, such as database tests, skip themselves in short mode. U
go test -short ./...
```
In an application with local plugins in a `go.work` workspace, run the tests of one plugin by its directory, for example `go test ./plugins/blog/...`.
In an application with local plugins in a `go.work` workspace, run the tests of one plugin by its directory, for example `go test ./plugins/acme/blog/...`.
## Unit tests without a database

View File

@@ -49,7 +49,7 @@ module: git.golem15.com/acme/acme
binary: acme
plugins:
- id: acme.blog
module: git.golem15.com/acme/acme/plugins/blog
module: git.golem15.com/acme/acme/plugins/acme/blog
build:
tags:
- summercms_s3

View File

@@ -20,18 +20,18 @@ summer make:model acme.blog Post
summer make:migration acme.blog AddPublishedAt
summer make:admin-controller acme.blog Posts
summer make:command acme.blog Publish
summer plugin:add plugins/blog
summer plugin:add plugins/acme/blog
summer build
```
| Command | Writes |
|---------|--------|
| `summer make:plugin acme.blog` | `plugins/blog` as its own Go module: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod`, `config/config.yaml`, `lang/en/lang.yaml`, `views/mail/welcome.htm` and a `doc.go` in `classes`, `console`, `controllers`, `jobs`, `middleware`, `models` and `updates` |
| `summer make:plugin acme.blog` | `plugins/acme/blog` as its own Go module: `plugin.go`, `routes.go`, `registry.gen.go`, `go.mod`, `config/config.yaml`, `lang/en/lang.yaml`, `views/mail/welcome.htm` and a `doc.go` in `classes`, `console`, `controllers`, `jobs`, `middleware`, `models` and `updates` |
| `summer make:model acme.blog Post` | `models/post.go` and `updates/<timestamp>_create_acme_blog_posts.go` |
| `summer make:migration acme.blog AddPublishedAt` | `updates/<timestamp>_add_published_at.go` |
| `summer make:admin-controller acme.blog Posts` | `controllers/posts.go`, `controllers/posts/config_form.yaml`, `controllers/posts/config_list.yaml`, `models/posts/fields.yaml` and `models/posts/columns.yaml` |
| `summer make:command acme.blog Publish` | `console/publish.go` |
| `summer plugin:add plugins/blog` | The plugin in `summer.yaml`, a `require` and a local `replace` in the application's `go.mod`, and the directory in `go.work` |
| `summer plugin:add plugins/acme/blog` | The plugin in `summer.yaml`, a `require` and a local `replace` in the application's `go.mod`, and the directory in `go.work` |
| `summer build` | `bin/acme`, the application binary with the plugin compiled in |
Each `make:` command also rewrites `registry.gen.go` and runs `go mod tidy` in the plugin. The sections below fill in what each generated file leaves empty. See [Scaffolding](../console/scaffolding.md) for every option of these commands.
@@ -39,7 +39,7 @@ Each `make:` command also rewrites `registry.gen.go` and runs `go mod tidy` in t
The copy in the framework repository has no `go.mod`, because it is a package of the framework module so that the framework's own `go test ./...` covers it. A plugin you scaffold is a module of its own, and its `go.mod` starts like this for an application whose module is `example.com/acme` (indirect requirements left out):
```text
module example.com/acme/plugins/blog
module example.com/acme/plugins/acme/blog
go 1.27.0

View File

@@ -451,21 +451,14 @@ func locatePluginDir(appDir, id string) (string, error) {
}
}
}
candidate := filepath.Join(appDir, "plugins", pluginName(id))
if !underRoot(appDir, candidate) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", candidate)
}
st, err := os.Stat(candidate)
dir, err := defaultPluginDir(appDir, id)
if err != nil {
if os.IsNotExist(err) {
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
}
return "", fmt.Errorf("build: stat %s: %w", candidate, err)
return "", err
}
if !st.IsDir() {
if dir == "" {
return "", pluginMissingHint(fmt.Errorf("build: plugin %s not found", id), id)
}
return candidate, nil
return dir, nil
}
func finishArtifact(ctx context.Context, plugin pluginRef) error {

View File

@@ -2,6 +2,7 @@ package build
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
@@ -10,6 +11,7 @@ import (
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -248,16 +250,16 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(dir, "plugins", "demo")
wantDir := filepath.Join(dir, "plugins", "golem15", "demo")
if pluginDir != wantDir {
t.Fatalf("plugin dir = %s, want %s", pluginDir, wantDir)
}
assertScaffoldFiles(t, pluginDir)
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil {
t.Fatal(err)
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil {
t.Fatalf("repeat plugin:add: %v", err)
}
@@ -272,7 +274,7 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
if last.ID != "golem15.demo" {
t.Fatalf("last plugin id = %q", last.ID)
}
if last.Module != m.Module+"/plugins/demo" {
if last.Module != m.Module+"/plugins/golem15/demo" {
t.Fatalf("last plugin module = %q", last.Module)
}
demoCount := 0
@@ -289,8 +291,8 @@ func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(work, []byte("./plugins/demo")) {
t.Fatalf("go.work missing ./plugins/demo:\n%s", work)
if !bytes.Contains(work, []byte("./plugins/golem15/demo")) {
t.Fatalf("go.work missing ./plugins/golem15/demo:\n%s", work)
}
// plugin:add writes go.work use paths relative to the go.work directory,
// so a committed go.work carries no machine-specific path.
@@ -376,15 +378,15 @@ func TestAddPluginRejectsConflictingID(t *testing.T) {
if _, err := MakePlugin(ctx, dir, "golem15.demo"); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile(filepath.Join(dir, "plugins", "demo", "plugin.go"))
src, err := os.ReadFile(filepath.Join(dir, "plugins", "golem15", "demo", "plugin.go"))
if err != nil {
t.Fatal(err)
}
patched := bytes.ReplaceAll(src, []byte(`return "golem15.demo"`), []byte(`return "golem15.hello"`))
if err := os.WriteFile(filepath.Join(dir, "plugins", "demo", "plugin.go"), patched, 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "plugins", "golem15", "demo", "plugin.go"), patched, 0o644); err != nil {
t.Fatal(err)
}
err = AddPlugin(ctx, dir, filepath.Join(dir, "plugins", "demo"))
err = AddPlugin(ctx, dir, filepath.Join(dir, "plugins", "golem15", "demo"))
if err == nil || !strings.Contains(err.Error(), "golem15.hello") {
t.Fatalf("want conflicting id error, got %v", err)
}
@@ -510,7 +512,7 @@ func TestScaffoldPluginSmoke(t *testing.T) {
}
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
@@ -691,7 +693,7 @@ func TestScaffoldCoreArtifacts(t *testing.T) {
t.Fatal("handwritten greeter plugin.go was rewritten")
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
@@ -913,7 +915,7 @@ func TestScaffoldAllArtifacts(t *testing.T) {
t.Fatal("handwritten plugin.go was rewritten")
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
@@ -938,7 +940,7 @@ func TestModelsLeaf(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "golem15", "demo")); err != nil {
t.Fatal(err)
}
modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
@@ -1183,7 +1185,7 @@ func assertScaffoldFiles(t *testing.T, pluginDir string) {
if !bytes.Contains(mod, []byte("toolchain go1.27.0")) {
t.Fatalf("plugin go.mod missing toolchain:\n%s", mod)
}
if !bytes.Contains(mod, []byte("/plugins/demo")) {
if !bytes.Contains(mod, []byte("/plugins/golem15/demo")) {
t.Fatalf("plugin go.mod missing module path:\n%s", mod)
}
@@ -1424,3 +1426,95 @@ func TestNextMigrationFileSortsAfterExisting(t *testing.T) {
}
})
}
// TestMakePluginVendorDirs: make:plugin writes plugins/<vendor>/<name>, so two
// vendors' plugins with the same name live side by side and both build.
func TestMakePluginVendorDirs(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
m, err := LoadManifest(filepath.Join(dir, "summer.yaml"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ id, vendor string }{
{"acme.blog", "acme"},
{"pixelpixel.blog", "pixelpixel"},
} {
pluginDir, err := MakePlugin(ctx, dir, tc.id)
if err != nil {
t.Fatalf("%s: %v", tc.id, err)
}
if want := filepath.Join(dir, "plugins", tc.vendor, "blog"); pluginDir != want {
t.Fatalf("%s dir = %s, want %s", tc.id, pluginDir, want)
}
mod, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
t.Fatal(err)
}
if want := m.Module + "/plugins/" + tc.vendor + "/blog"; mod != want {
t.Fatalf("%s module = %s, want %s", tc.id, mod, want)
}
if pkg, err := packageNameOf(filepath.Join(pluginDir, "plugin.go")); err != nil || pkg != "blog" {
t.Fatalf("%s package = %q, %v", tc.id, pkg, err)
}
if out, err := execGo(t, pluginDir, "build", "./...").CombinedOutput(); err != nil {
t.Fatalf("%s does not build: %v\n%s", tc.id, err, out)
}
}
_, err = MakePlugin(ctx, dir, "acme.blog")
if err == nil {
t.Fatal("second make:plugin acme.blog want error")
}
var hint *bonfire.HintError
if !errors.As(err, &hint) || !strings.Contains(hint.Try, "plugin:add plugins/acme/blog") {
t.Fatalf("existing-directory hint does not name plugins/acme/blog: %#v", hint)
}
// examples/hello keeps plugins/greeter in the older layout: a vendor
// named greeter would nest a module inside it.
if _, err := MakePlugin(ctx, dir, "greeter.extra"); err == nil || !strings.Contains(err.Error(), "not a vendor directory") {
t.Fatalf("make:plugin inside a legacy plugin module: %v", err)
}
}
// TestLocatePluginLegacyLayout: a plugin in the older plugins/<name> layout,
// not listed in summer.yaml, is still found by the make: commands.
func TestLocatePluginLegacyLayout(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
legacy := filepath.Join(dir, "plugins", "demo")
if err := os.Rename(pluginDir, legacy); err != nil {
t.Fatal(err)
}
got, err := locatePluginDir(dir, "golem15.demo")
if err != nil || got != legacy {
t.Fatalf("locatePluginDir = %q, %v; want %s", got, err, legacy)
}
res, err := MakeModel(ctx, dir, "golem15.demo", "Album", true)
if err != nil {
t.Fatal(err)
}
if res.PluginDir != legacy {
t.Fatalf("make:model plugin dir = %s, want %s", res.PluginDir, legacy)
}
if _, err := os.Stat(filepath.Join(legacy, "models", "album.go")); err != nil {
t.Fatal(err)
}
if _, err := MakePlugin(ctx, dir, "golem15.demo"); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("make:plugin over a legacy copy: %v", err)
}
// With both layouts present, plugins/<vendor>/<name> wins.
vendorDir := filepath.Join(dir, "plugins", "golem15", "demo")
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
t.Fatal(err)
}
if got, err := locatePluginDir(dir, "golem15.demo"); err != nil || got != vendorDir {
t.Fatalf("locatePluginDir with both layouts = %q, %v; want %s", got, err, vendorDir)
}
}

View File

@@ -23,7 +23,11 @@ func checkModelsLeaf(appDir string, m Manifest) error {
for _, p := range m.Plugins {
pluginDir := localModuleDir(appDir, p.Module)
if pluginDir == "" {
pluginDir = filepath.Join(appDir, "plugins", pluginName(p.ID))
dir, err := defaultPluginDir(appDir, p.ID)
if err != nil || dir == "" {
continue
}
pluginDir = dir
}
if !underRoot(appDir, pluginDir) {
continue

View File

@@ -104,9 +104,9 @@ func ValidateAppName(name string) error {
}
switch name {
case "main":
return fmt.Errorf("build: app name %q is reserved: plugins/main would be a program, not an importable package", name)
return fmt.Errorf("build: app name %q is reserved: plugins/golem15/main would be a program, not an importable package", name)
case "internal":
return fmt.Errorf("build: app name %q is reserved: Go forbids importing plugins/internal from the app", name)
return fmt.Errorf("build: app name %q is reserved: Go forbids importing plugins/golem15/internal from the app", name)
}
return nil
}
@@ -459,5 +459,5 @@ func addStarterPlugin(ctx context.Context, name, target string) error {
if _, err := makePlugin(ctx, target, "golem15."+name, pluginOptions{StarterRoute: true}); err != nil {
return err
}
return AddPlugin(ctx, target, filepath.Join("plugins", name))
return AddPlugin(ctx, target, filepath.Join("plugins", "golem15", name))
}

View File

@@ -135,15 +135,15 @@ func TestNewGolden(t *testing.T) {
"go.mod", "go.work", "summer.yaml", "main.go", "plugins.gen.go",
"config/app.yaml", "config/database.yaml", "config/http.yaml", "config/storage.yaml", "config/log.yaml",
".env", ".env.example", ".gitignore", "compose.yaml", "CLAUDE.md",
"plugins/demo/plugin.go", "plugins/demo/routes.go",
"plugins/golem15/demo/plugin.go", "plugins/golem15/demo/routes.go",
} {
assertGolden(t, "new", rel, readAppFile(t, dir, rel))
}
assertGolden(t, "new", "next-steps.txt", out)
pluginMod := readAppFile(t, dir, "plugins/demo/go.mod")
if !bytes.Contains(pluginMod, []byte("replace git.golem15.com/golem15/summercms => ../../../summercms")) {
t.Errorf("plugins/demo/go.mod lacks the relative framework replace:\n%s", pluginMod)
pluginMod := readAppFile(t, dir, "plugins/golem15/demo/go.mod")
if !bytes.Contains(pluginMod, []byte("replace git.golem15.com/golem15/summercms => ../../../../summercms")) {
t.Errorf("plugins/golem15/demo/go.mod lacks the relative framework replace:\n%s", pluginMod)
}
if _, err := os.Stat(filepath.Join(dir, "go.sum")); !os.IsNotExist(err) {
t.Errorf("app root go.sum exists or stat failed: %v", err)

View File

@@ -141,8 +141,8 @@ func TestNewAppSmoke(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(work, []byte("./plugins/demo")) {
t.Fatalf("go.work lacks ./plugins/demo:\n%s", work)
if !bytes.Contains(work, []byte("./plugins/golem15/demo")) {
t.Fatalf("go.work lacks ./plugins/golem15/demo:\n%s", work)
}
for _, line := range strings.Split(string(work), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "/") {
@@ -555,7 +555,7 @@ func TestNewGitFirstCommit(t *testing.T) {
}
return false
}
for _, want := range []string{".env.example", ".gitignore", "go.work", "main.go", "plugins.gen.go", "AGENTS.md", "CLAUDE.md", "summer.yaml", "plugins/demo/plugin.go"} {
for _, want := range []string{".env.example", ".gitignore", "go.work", "main.go", "plugins.gen.go", "AGENTS.md", "CLAUDE.md", "summer.yaml", "plugins/golem15/demo/plugin.go"} {
if !has(want) {
t.Errorf("%s is not tracked: %v", want, tracked)
}

View File

@@ -58,7 +58,9 @@ const (
toolchainLine = "toolchain go1.27.0"
)
// MakePlugin scaffolds a compiling plugin module under plugins/<name>.
// MakePlugin scaffolds a compiling plugin module under plugins/<vendor>/<name>
// for the ID <vendor>.<name>; its module path is the application module
// followed by /plugins/<vendor>/<name>, and its Go package is <name>.
func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
return makePlugin(ctx, startDir, id, pluginOptions{})
}
@@ -73,24 +75,39 @@ func makePlugin(ctx context.Context, startDir, id string, opts pluginOptions) (s
if err != nil {
return "", err
}
name := pluginName(id)
pluginDir := filepath.Join(appDir, "plugins", name)
vendor, name := pluginVendor(id), pluginName(id)
pluginDir := filepath.Join(appDir, "plugins", vendor, name)
if !underRoot(appDir, pluginDir) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
}
addArg := "plugins/" + vendor + "/" + name
if _, err := os.Stat(pluginDir); err == nil {
return "", bonfire.WithHint(fmt.Errorf("build: plugin directory %s already exists", pluginDir),
"summer make:plugin creates a new directory under plugins/ and never overwrites one; register an existing plugin with plugin:add.",
"{bin} plugin:add "+filepath.Join("plugins", name))
"{bin} plugin:add "+addArg)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
}
// A legacy plugins/<vendor> module would swallow the new directory, and a
// legacy plugins/<name> with this ID is the same plugin.
vendorDir := filepath.Join(appDir, "plugins", vendor)
if _, err := os.Stat(filepath.Join(vendorDir, "go.mod")); err == nil {
return "", bonfire.WithHint(fmt.Errorf("build: %s is a plugin module, not a vendor directory", vendorDir),
"summer make:plugin writes plugins/<vendor>/<name>; plugins/"+vendor+" already holds a plugin in the older plugins/<name> layout. Move that plugin first, or choose another vendor.",
"{bin} make:plugin <vendor>."+name)
}
legacyDir := filepath.Join(appDir, "plugins", name)
if got, err := pluginIDFromGo(filepath.Join(legacyDir, "plugin.go")); err == nil && got == id {
return "", bonfire.WithHint(fmt.Errorf("build: plugin %s already exists in %s", id, legacyDir),
"summer make:plugin never creates a second copy of a plugin; register the existing one with plugin:add.",
"{bin} plugin:add plugins/"+name)
}
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return "", err
}
modPath := m.Module + "/plugins/" + name
modPath := m.Module + "/" + addArg
if err := validateModulePath(modPath); err != nil {
return "", fmt.Errorf("build: plugin module: %w", err)
}
@@ -197,6 +214,44 @@ func pluginName(id string) string {
return name
}
// pluginVendor returns the vendor part of a plugin ID, before the dot.
func pluginVendor(id string) string {
vendor, _, _ := strings.Cut(id, ".")
return vendor
}
// pluginDirCandidates lists the default directories of a plugin that
// summer.yaml does not place, in lookup order: plugins/<vendor>/<name>, the
// layout make:plugin writes, then the older plugins/<name>.
func pluginDirCandidates(appDir, id string) []string {
return []string{
filepath.Join(appDir, "plugins", pluginVendor(id), pluginName(id)),
filepath.Join(appDir, "plugins", pluginName(id)),
}
}
// defaultPluginDir returns the first of pluginDirCandidates that is an
// existing directory, or "" when none is. A candidate outside the app root is
// an error.
func defaultPluginDir(appDir, id string) (string, error) {
for _, dir := range pluginDirCandidates(appDir, id) {
if !underRoot(appDir, dir) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", dir)
}
st, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return "", fmt.Errorf("build: stat %s: %w", dir, err)
}
if st.IsDir() {
return dir, nil
}
}
return "", nil
}
func writePluginScaffold(pluginDir string, data stubData) error {
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
return fmt.Errorf("build: create plugin dir: %w", err)

View File

@@ -141,8 +141,8 @@ summer build # generate main.go and plugins.gen.go, build
summer dev # watch sources and rebuild
summer migrate # run plugin migrations
summer serve # start the HTTP server
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/golem15/<name>
summer plugin:add plugins/<vendor>/<name> # register it in summer.yaml, go.mod and go.work
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
```
@@ -150,7 +150,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
## Layout
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules.{{if not .NoPlugin}} `plugins/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}}
- `plugins/`: local plugin modules, one directory per vendor (`plugins/<vendor>/<name>`).{{if not .NoPlugin}} `plugins/golem15/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}}
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.
@@ -184,7 +184,7 @@ Next steps, inside sm-{{.Name}}-app:
summer serve
{{if .NoPlugin}}
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
and summer plugin:add plugins/<name>, then run summer build again.
and summer plugin:add plugins/golem15/<name>, then run summer build again.
{{else}}
Then, from another terminal:
curl http://127.0.0.1:8080/

View File

@@ -21,8 +21,8 @@ summer build # generate main.go and plugins.gen.go, build
summer dev # watch sources and rebuild
summer migrate # run plugin migrations
summer serve # start the HTTP server
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/golem15/<name>
summer plugin:add plugins/<vendor>/<name> # register it in summer.yaml, go.mod and go.work
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
```
@@ -30,7 +30,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
## Layout
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules.
- `plugins/`: local plugin modules, one directory per vendor (`plugins/<vendor>/<name>`).
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.

View File

@@ -7,7 +7,7 @@ Next steps, inside sm-demo-app:
summer serve
The app has no plugins yet. Add one with summer make:plugin golem15.<name>
and summer plugin:add plugins/<name>, then run summer build again.
and summer plugin:add plugins/golem15/<name>, then run summer build again.
Admin, at http://127.0.0.1:8080/backend once a plugin registers an admin
controller (.env already holds SUMMER_ADMIN__JWT__SECRET):

View File

@@ -21,8 +21,8 @@ summer build # generate main.go and plugins.gen.go, build
summer dev # watch sources and rebuild
summer migrate # run plugin migrations
summer serve # start the HTTP server
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/<name>
summer plugin:add plugins/<name> # register it in summer.yaml, go.mod and go.work
summer make:plugin golem15.<name> # scaffold a plugin module under plugins/golem15/<name>
summer plugin:add plugins/<vendor>/<name> # register it in summer.yaml, go.mod and go.work
summer make:admin-controller golem15.<plugin> <Name> # scaffold an admin controller with its YAML
summer admin:create --email you@example.com --superuser # create a backend administrator (password prompted)
```
@@ -30,7 +30,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
## Layout
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
- `plugins/`: local plugin modules, one directory per vendor (`plugins/<vendor>/<name>`). `plugins/golem15/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.

View File

@@ -5,10 +5,10 @@ go 1.27.0
toolchain go1.27.0
require (
git.golem15.com/golem15/sm-demo-app/plugins/demo v0.0.0
git.golem15.com/golem15/sm-demo-app/plugins/golem15/demo v0.0.0
git.golem15.com/golem15/summercms v0.0.0
)
replace git.golem15.com/golem15/summercms => ../summercms
replace git.golem15.com/golem15/sm-demo-app/plugins/demo => ./plugins/demo
replace git.golem15.com/golem15/sm-demo-app/plugins/golem15/demo => ./plugins/golem15/demo

View File

@@ -4,5 +4,5 @@ toolchain go1.27.0
use (
.
./plugins/demo
./plugins/golem15/demo
)

View File

@@ -3,7 +3,7 @@
package main
import (
_ "git.golem15.com/golem15/sm-demo-app/plugins/demo"
_ "git.golem15.com/golem15/sm-demo-app/plugins/golem15/demo"
)
// PluginIDs is the ordered plugin list from summer.yaml.

View File

@@ -2,4 +2,4 @@ module: git.golem15.com/golem15/sm-demo-app
binary: demo
plugins:
- id: golem15.demo
module: git.golem15.com/golem15/sm-demo-app/plugins/demo
module: git.golem15.com/golem15/sm-demo-app/plugins/golem15/demo