From a95a014b52c73282918f4e353955f692185ce59c Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 18:55:46 +0100 Subject: [PATCH] fix(build): scaffold plugins under plugins// (quick-261028-pq8) - make:plugin writes plugins// with module /plugins//; the package name stays - make:* and the models-leaf check look in summer.yaml, then plugins//, then the older plugins/ - summer new puts its starter plugin at plugins/golem15/ - refuse a vendor directory that is a legacy plugin module and a second copy of a plugin that already sits in plugins/ - help text and docs, with an upgrading note: moving is optional --- cmd/summer/main.go | 16 +-- cmd/summer/main_test.go | 6 +- .../architecture/go-modules-and-workspaces.md | 10 +- docs/console/scaffolding.md | 23 +++- docs/examples/blog/scaffold_layout_test.go | 2 +- docs/plugins/registration.md | 4 +- docs/plugins/testing.md | 2 +- docs/services/storage.md | 2 +- docs/setup/porting-a-plugin.md | 8 +- internal/build/artifact.go | 15 +-- internal/build/build_test.go | 122 ++++++++++++++++-- internal/build/leaf.go | 6 +- internal/build/new.go | 6 +- internal/build/new_golden_test.go | 8 +- internal/build/new_test.go | 6 +- internal/build/scaffold.go | 65 +++++++++- internal/build/stubs/new.tmpl | 8 +- .../testdata/new-no-plugin/CLAUDE.md.golden | 6 +- .../new-no-plugin/next-steps.txt.golden | 2 +- internal/build/testdata/new/CLAUDE.md.golden | 6 +- internal/build/testdata/new/go.mod.golden | 4 +- internal/build/testdata/new/go.work.golden | 2 +- .../build/testdata/new/plugins.gen.go.golden | 2 +- ... plugins__golem15__demo__plugin.go.golden} | 0 ... plugins__golem15__demo__routes.go.golden} | 0 .../build/testdata/new/summer.yaml.golden | 2 +- 26 files changed, 247 insertions(+), 86 deletions(-) rename internal/build/testdata/new/{plugins__demo__plugin.go.golden => plugins__golem15__demo__plugin.go.golden} (100%) rename internal/build/testdata/new/{plugins__demo__routes.go.golden => plugins__golem15__demo__routes.go.golden} (100%) diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 472013f6..2f17a272 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -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/ in the current application: a Go module with plugin.go + Long: `Creates plugins// 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 { diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index dfb47337..070bf113 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -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) } diff --git a/docs/architecture/go-modules-and-workspaces.md b/docs/architecture/go-modules-and-workspaces.md index 1e1d5260..79a2cc76 100644 --- a/docs/architecture/go-modules-and-workspaces.md +++ b/docs/architecture/go-modules-and-workspaces.md @@ -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/` 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//` 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 ``` diff --git a/docs/console/scaffolding.md b/docs/console/scaffolding.md index d5d8b77e..719dd494 100644 --- a/docs/console/scaffolding.md +++ b/docs/console/scaffolding.md @@ -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/` | The starter plugin `golem15.`, 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/` | The starter plugin `golem15.`, 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 ` 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--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/` 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//` 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 `/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/` 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//`, then in `plugins/`. + +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 ``` diff --git a/docs/examples/blog/scaffold_layout_test.go b/docs/examples/blog/scaffold_layout_test.go index 0b5ee41e..37e268f2 100644 --- a/docs/examples/blog/scaffold_layout_test.go +++ b/docs/examples/blog/scaffold_layout_test.go @@ -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 { diff --git a/docs/plugins/registration.md b/docs/plugins/registration.md index a808de2a..5b9592ce 100644 --- a/docs/plugins/registration.md +++ b/docs/plugins/registration.md @@ -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 diff --git a/docs/plugins/testing.md b/docs/plugins/testing.md index 1a1f1d39..4df1e09b 100644 --- a/docs/plugins/testing.md +++ b/docs/plugins/testing.md @@ -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 diff --git a/docs/services/storage.md b/docs/services/storage.md index 237cb70b..4aa2a137 100644 --- a/docs/services/storage.md +++ b/docs/services/storage.md @@ -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 diff --git a/docs/setup/porting-a-plugin.md b/docs/setup/porting-a-plugin.md index 1f696d34..5eb652fc 100644 --- a/docs/setup/porting-a-plugin.md +++ b/docs/setup/porting-a-plugin.md @@ -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/_create_acme_blog_posts.go` | | `summer make:migration acme.blog AddPublishedAt` | `updates/_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 diff --git a/internal/build/artifact.go b/internal/build/artifact.go index 92473ca3..5aff5f06 100644 --- a/internal/build/artifact.go +++ b/internal/build/artifact.go @@ -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 { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 74622eb4..d03b1d31 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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//, 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/ 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// 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) + } +} diff --git a/internal/build/leaf.go b/internal/build/leaf.go index 245bd173..dc953b8a 100644 --- a/internal/build/leaf.go +++ b/internal/build/leaf.go @@ -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 diff --git a/internal/build/new.go b/internal/build/new.go index 67735ccb..36a2bdb5 100644 --- a/internal/build/new.go +++ b/internal/build/new.go @@ -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)) } diff --git a/internal/build/new_golden_test.go b/internal/build/new_golden_test.go index 9df84725..853f60eb 100644 --- a/internal/build/new_golden_test.go +++ b/internal/build/new_golden_test.go @@ -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) diff --git a/internal/build/new_test.go b/internal/build/new_test.go index 2f11578b..d3fc4eda 100644 --- a/internal/build/new_test.go +++ b/internal/build/new_test.go @@ -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) } diff --git a/internal/build/scaffold.go b/internal/build/scaffold.go index 0ad744e9..6c48957c 100644 --- a/internal/build/scaffold.go +++ b/internal/build/scaffold.go @@ -58,7 +58,9 @@ const ( toolchainLine = "toolchain go1.27.0" ) -// MakePlugin scaffolds a compiling plugin module under plugins/. +// MakePlugin scaffolds a compiling plugin module under plugins// +// for the ID .; its module path is the application module +// followed by /plugins//, and its Go package is . 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/ module would swallow the new directory, and a + // legacy plugins/ 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//; plugins/"+vendor+" already holds a plugin in the older plugins/ layout. Move that plugin first, or choose another vendor.", + "{bin} make:plugin ."+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//, the +// layout make:plugin writes, then the older plugins/. +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) diff --git a/internal/build/stubs/new.tmpl b/internal/build/stubs/new.tmpl index 477fa320..1d048ce3 100644 --- a/internal/build/stubs/new.tmpl +++ b/internal/build/stubs/new.tmpl @@ -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. # scaffold a plugin module under plugins/ -summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.work +summer make:plugin golem15. # scaffold a plugin module under plugins/golem15/ +summer plugin:add plugins// # register it in summer.yaml, go.mod and go.work summer make:admin-controller golem15. # 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//`).{{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. -and summer plugin:add plugins/, then run summer build again. +and summer plugin:add plugins/golem15/, then run summer build again. {{else}} Then, from another terminal: curl http://127.0.0.1:8080/ diff --git a/internal/build/testdata/new-no-plugin/CLAUDE.md.golden b/internal/build/testdata/new-no-plugin/CLAUDE.md.golden index d9543c8b..dd511096 100644 --- a/internal/build/testdata/new-no-plugin/CLAUDE.md.golden +++ b/internal/build/testdata/new-no-plugin/CLAUDE.md.golden @@ -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. # scaffold a plugin module under plugins/ -summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.work +summer make:plugin golem15. # scaffold a plugin module under plugins/golem15/ +summer plugin:add plugins// # register it in summer.yaml, go.mod and go.work summer make:admin-controller golem15. # 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//`). - `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. diff --git a/internal/build/testdata/new-no-plugin/next-steps.txt.golden b/internal/build/testdata/new-no-plugin/next-steps.txt.golden index bf7fd045..8d965e42 100644 --- a/internal/build/testdata/new-no-plugin/next-steps.txt.golden +++ b/internal/build/testdata/new-no-plugin/next-steps.txt.golden @@ -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. -and summer plugin:add plugins/, then run summer build again. +and summer plugin:add plugins/golem15/, 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): diff --git a/internal/build/testdata/new/CLAUDE.md.golden b/internal/build/testdata/new/CLAUDE.md.golden index 383db2de..36b52265 100644 --- a/internal/build/testdata/new/CLAUDE.md.golden +++ b/internal/build/testdata/new/CLAUDE.md.golden @@ -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. # scaffold a plugin module under plugins/ -summer plugin:add plugins/ # register it in summer.yaml, go.mod and go.work +summer make:plugin golem15. # scaffold a plugin module under plugins/golem15/ +summer plugin:add plugins// # register it in summer.yaml, go.mod and go.work summer make:admin-controller golem15. # 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//`). `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. diff --git a/internal/build/testdata/new/go.mod.golden b/internal/build/testdata/new/go.mod.golden index 1b019422..5cdde72e 100644 --- a/internal/build/testdata/new/go.mod.golden +++ b/internal/build/testdata/new/go.mod.golden @@ -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 diff --git a/internal/build/testdata/new/go.work.golden b/internal/build/testdata/new/go.work.golden index 937d47ff..151a50b2 100644 --- a/internal/build/testdata/new/go.work.golden +++ b/internal/build/testdata/new/go.work.golden @@ -4,5 +4,5 @@ toolchain go1.27.0 use ( . - ./plugins/demo + ./plugins/golem15/demo ) diff --git a/internal/build/testdata/new/plugins.gen.go.golden b/internal/build/testdata/new/plugins.gen.go.golden index 2dc017e7..5a4a2300 100644 --- a/internal/build/testdata/new/plugins.gen.go.golden +++ b/internal/build/testdata/new/plugins.gen.go.golden @@ -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. diff --git a/internal/build/testdata/new/plugins__demo__plugin.go.golden b/internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden similarity index 100% rename from internal/build/testdata/new/plugins__demo__plugin.go.golden rename to internal/build/testdata/new/plugins__golem15__demo__plugin.go.golden diff --git a/internal/build/testdata/new/plugins__demo__routes.go.golden b/internal/build/testdata/new/plugins__golem15__demo__routes.go.golden similarity index 100% rename from internal/build/testdata/new/plugins__demo__routes.go.golden rename to internal/build/testdata/new/plugins__golem15__demo__routes.go.golden diff --git a/internal/build/testdata/new/summer.yaml.golden b/internal/build/testdata/new/summer.yaml.golden index 03979a27..79eb2f5d 100644 --- a/internal/build/testdata/new/summer.yaml.golden +++ b/internal/build/testdata/new/summer.yaml.golden @@ -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