Files
summercms/docs/plugins/marketplace.md
Jakub Zych 2f3eba0cb1 fix(34): WR-03 refuse user info in https git URLs
validateGitURL refused only a password, but GitHub, GitLab and Gitea take
a token as the user part of an https URL. Such a URL was printed and
written to .gitmodules by git submodule add, and so committed with the
application. An https URL with any user info is now refused with a hint
to use a credential helper; the user of an ssh URL stays allowed.
2026-10-29 22:09:39 +01:00

227 lines
20 KiB
Markdown

---
title: Publishing plugins
description: Describe a plugin with a generated plugin.yaml, install listed plugins with summer plugin:install, and read the registry API.
section: plugins
order: 50
---
# Publishing plugins
A plugin that lives in its own repository can be listed on the SummerCMS marketplace. The listing reads one file from the repository, `plugin.yaml`, which the `summer` tool generates from the plugin's own sources. Nothing in it is typed by hand, so the ID, module path and version requirements the marketplace shows are the ones the code has.
This is the counterpart of WinterCMS's `pluginDetails()` in `Plugin.php` together with the `composer.json` a marketplace plugin carries.
## The plugin.yaml manifest
`plugin.yaml` sits next to `plugin.go`, one per plugin package. A repository whose module holds a second plugin in a subdirectory has a second `plugin.yaml` in that subdirectory.
```yaml
# Generated by summer plugin:manifest. Do not edit; rerun the command.
schema: 1
id: acme.blog
module: example.com/acme/sm-blog-plugin
package: example.com/acme/sm-blog-plugin
name: Blog
description: Posts, categories and a feed for SummerCMS applications.
framework: v0.6.0
requires:
acme.translate: v0.2.0
overrides:
name: Blog
```
| Field | Comes from |
|-------|------------|
| `schema` | The format version (`kiosk.SchemaVersion`). A reader that knows an older schema refuses the file and asks you to update `summer`. |
| `id` | What `ID()` in `plugin.go` returns: a string literal or a constant (of the same package, of another package of the module, or of a required module). The command reads the source and never runs it; any other expression, such as a function call or a variable, is an error naming `ID()`. |
| `module` | The `module` line of the nearest `go.mod`: what `go get` takes. |
| `package` | The plugin's import path: what `summer.yaml` lists. It differs from `module` for a plugin in a subdirectory. |
| `name` | The README's H1, or the `--name` override. |
| `description` | The first paragraph after the README's H1 (the one-sentence summary of the standard README structure), or the `--description` override. |
| `framework` | The version `go.mod` requires the framework (`git.golem15.com/golem15/summercms`) at: the oldest framework release the plugin works with. |
| `requires` | Each plugin ID `Requires()` returns (literals or constants, as for `id`), with the version `go.mod` requires its module at. A requirement at `v0.0.0` is an error, because nobody can install that version; a pseudo-version is accepted with a warning. The value is empty for a plugin in the same module, and for one whose module is not a `go.mod` require. Left out when `Requires()` returns none. |
| `overrides` | The `--name` and `--description` values, kept across regenerations. Left out when neither is set. |
Versions are minimums in Go's sense (`v0.6.0` means "v0.6.0 or newer"), never ranges: Go's minimal version selection picks the version, and a major version change moves the module path.
## Generating and checking it
Run `summer plugin:manifest` in the plugin directory, or pass the directory:
```sh
summer plugin:manifest
summer plugin:manifest plugins/acme/blog
summer plugin:manifest --pretend
```
`--pretend` prints the file instead of writing it. Commit `plugin.yaml`, and rerun the command after changing what it reads: the README's H1 or summary, `ID()` or `Requires()`, or a `go.mod` require.
A README whose H1 is the plugin ID (`# acme.blog`) has no display name to offer, so the command stops and asks for one. Give it once; it is stored under `overrides:` and reused:
```sh
summer plugin:manifest --name="Blog"
summer plugin:manifest --description="Posts, categories and a feed."
summer plugin:manifest --name=""
```
The last line clears the name override, so the README's H1 is used again.
`--check` writes nothing. It regenerates the manifest in memory and exits with status 1, printing what it expected, when `plugin.yaml` is missing or differs from the committed file:
```sh
summer plugin:manifest --check
```
Run it before tagging a release, so the tag carries a manifest that matches its code.
## Testing the manifest in your plugin
Add one test to the plugin, so `go test` fails as soon as `plugin.yaml` no longer matches the code. It imports `git.golem15.com/golem15/summercms/modules/kiosk` and passes the plugin directory, which is `"."` in a plugin's own package (the framework's copy below checks a fixture):
```go src=modules/kiosk/checkt_test.go#TestPluginManifest
// TestPluginManifest is the test a plugin repository adds next to its
// plugin.go, with "." as the directory. Here it guards the committed
// manifest of the literal fixture.
func TestPluginManifest(t *testing.T) {
kiosk.CheckT(t, "testdata/literal")
}
```
`kiosk.CheckT` runs the same check as `summer plugin:manifest --check` and names the command to rerun. A nested plugin gets its own test in its own directory.
## Installed plugin metadata
`summer build` reads the `plugin.yaml` of every plugin the application lists and bakes the name and description into the generated `plugins.gen.go` as `PluginInfo`, next to each plugin's version and the framework version (`FrameworkVersion`). At start-up the application publishes them as `party.Installed`, which the admin's Plugins screen reads. [Go modules and workspaces](../architecture/go-modules-and-workspaces.md#the-summeryaml-manifest) explains where the versions come from.
## Installing plugins
`summer plugin:install` adds a listed plugin to an application. Run it inside the application:
```sh
summer plugin:install acme.blog
summer plugin:install acme.blog@v0.3.0
summer plugin:install acme.blog --yes
```
The command asks the registry for the plugin's releases and picks the newest one whose framework minimum the application meets. The application's framework version is the effective one: the newest tag of a local framework checkout when `go.mod` or `go.work` replaces the framework with a directory, otherwise the version `go.mod` requires. `@vX.Y.Z` pins a release, which must meet the framework minimum too. When every release needs a newer framework, the command stops and names the framework version to move to; it never changes the framework requirement itself.
The plugin is installed as a Go module: one `go get <module>@<version>` in the application, then an entry with its ID and package in `summer.yaml`. The edit appends to the `plugins:` list and keeps the file's comments and blank lines. Run `summer build` afterwards: it generates the import, and the new require in `go.mod` loses its `// indirect` marker the next time you run `go mod tidy`. A plugin that is already in `summer.yaml` is not installed again; move it to a newer release with `summer plugin:update`.
### Required plugins
A plugin's `requires` (from its `plugin.yaml`, as the registry lists it for the chosen release) are installed with it. For each required plugin the application does not have yet, the command picks the newest release that the framework version meets and that is at least the required minimum, then resolves that plugin's own requirements the same way. Required plugins come first in the list and in `summer.yaml`, and all module installs go through a single `go get`, so Go's minimal version selection sees the whole set. Two plugins that ship in the same module are installed from the same release of it.
A plugin in a module the application already has, such as a second plugin package in the repository of one that is installed, is installed at the version the application has and moves nothing: no `go get`, no second clone, only its `summer.yaml` entry (the plan marks it `module already in the application`). This holds with `--source` too, in whatever form the module is installed. When the registry has no release of the plugin at that version, or the version is not a release (a pseudo-version, an untagged checkout), the command stops and names `summer plugin:update` for the installed plugin. A git URL whose repository is already a submodule of the application, or whose module the application already has, is refused.
A required plugin the application already has is not touched, but its version must be at least the minimum: the go.mod require for a module install, the tag `git describe` finds for a plugin checked out as a submodule. When it is older, the command stops and tells you to run `summer plugin:update <id>` first. A version it cannot read, such as that of a plugin kept in the application's own repository, is accepted with a warning. Requirements that form a cycle in the registry data are reported as an error.
### Confirmation
Before changing anything, the command prints each plugin it is about to add, one line per plugin, with `module` or `submodule` as the mode:
```text
Installing 2 plugins into /srv/acme (framework v0.6.2):
acme.translate@v0.2.1 (module)
acme.blog@v0.3.0 (module)
```
Then it asks for confirmation, with "no" as the default. `--yes` skips the question. A run that cannot ask (no terminal, `-n` or `-q`) stops with an error naming `--yes` unless it is given. Declining changes nothing. When a step fails after the confirmation, the command undoes the run: it restores `go.mod`, `go.sum`, `go.work`, `summer.yaml` and `.gitmodules` and removes the submodules it added, so the next attempt starts clean. Anything it cannot undo is named in the error with the command that finishes the job.
### Installing from source
`--source` installs the plugin, and every required plugin it brings, as git submodules instead of Go modules:
```sh
summer plugin:install acme.blog --source
```
Each repository (the `repository` the registry lists, which must be an `https` URL) is cloned, checked out at the chosen release tag and added as a submodule in `plugins/<vendor>/<name>`, named after the ID in the repository root's `plugin.yaml`. A plugin in a subdirectory of its repository lives inside that directory. The `plugin.yaml` in the checkout must describe the plugin the registry listed (same ID and module), or the install is refused. The plugin is then registered like `summer plugin:add` does it, with the tag as the version `go.mod` requires, and `go.work` gets the replace overrides the plugin's own `go.mod` needs to build inside the application (see [Plugins installed as submodules](../architecture/go-modules-and-workspaces.md#plugins-installed-as-submodules)). Commit `.gitmodules`, the submodule, `go.mod`, `go.work` and `summer.yaml` together.
A submodule needs a git repository: in an application that is not one, `--source` stops before cloning anything.
### Installing from a git URL
A plugin that is not listed, such as a private one, installs from its repository:
```sh
summer plugin:install https://git.example.com/acme/sm-blog-plugin.git
summer plugin:install git@git.example.com:acme/sm-blog-plugin.git
summer plugin:install ssh://git@git.example.com/acme/sm-blog-plugin.git
```
The repository is cloned with your own git setup (credential helpers, SSH keys, `insteadOf` rules) and never sent to the registry. The command reads `plugin.yaml` at the repository's release tags, newest first, and checks out the newest tag whose framework minimum the application meets. When no tag fits, it takes the default branch head and prints a warning. A git URL install is always a submodule install, with the same layout and `go.work` overrides as `--source`.
The plugin's required plugins are looked up in the registry by ID and installed as submodules too. With `--offline` no registry call is made, so every required plugin must already be in `summer.yaml`; a missing one stops the install and is named.
Only `https://`, `ssh://` and scp-like `user@host:path` addresses are accepted. Git runs with `GIT_ALLOW_PROTOCOL=https:ssh`, so `ext::` and `file::` transports, `file://` and plain `http://` are refused, as is an address that starts with `-` or carries a password, and an `https://` address with any user part, since a token there (`https://<token>@github.com/...`) would be written to `.gitmodules` and committed. Use a credential helper instead; the user of an `ssh://` or `user@host:path` address is fine.
### Updating plugins
`summer plugin:update` moves installed plugins to their newest release that the application's framework version allows:
```sh
summer plugin:update acme.blog
summer plugin:update
summer plugin:update --yes
summer plugin:update --offline
```
With an ID it updates that plugin; without one it goes through every plugin in `summer.yaml`. A module install moves with `go get <module>@<version>`, and only to a release newer than the one installed (`kiosk.Newer`): a module required at a pseudo-version, such as `v0.4.1-0.20261001120000-abcdef123456` after `go get <module>@main`, is a commit after `v0.4.0`, so it moves to `v0.4.1` or later and never back to `v0.4.0`. A pseudo-version with no release before its commit (`v0.0.0-<timestamp>-<hash>`) or a pre-release is left as it is; pin a release with `go get <module>@<tag>` first. A submodule install fetches the tags of its repository, is checked out at the release tag, and `go.mod` requires that tag; the `go.work` overrides are refreshed, so a version pin follows a moved framework requirement. A listed plugin's releases come from the registry. A submodule the registry does not list, such as a plugin installed from a git URL, is updated from its own tags, reading `plugin.yaml` at each tag for the framework minimum. `--offline` makes no registry call and updates submodules from their own tags only.
The command prints what it will move (`acme.blog v0.1.0 -> v0.1.1 (module)`) and asks for confirmation like `summer plugin:install`; `--yes` skips the question. A newer release that needs a newer framework is named but not installed. The plugins it leaves as they are come under "Not updated" with the reason: plugins that are part of the application's own repository, module installs the registry does not list, plugins already at their newest compatible release, and releases that require a plugin the application lacks or has at too old a version. The command exits with an error only when an update it resolved could not be carried out, such as a release the registry lists but the repository has no tag for. A failure while updating checks every moved submodule out where it was and restores `go.mod`, `go.sum` and `go.work`. Run `summer build` afterwards.
### The registry setting
The registry is `https://summercms.io` unless the application's `plugins.registry_url` setting names another one:
```yaml
# config/plugins.yaml
registry_url: https://registry.example.com
```
The environment variable `SUMMER_PLUGINS__REGISTRY_URL` overrides the file. The value must be the final origin, because the client does not follow redirects. An empty value disables registry calls: installs by ID then fail, and the admin's Plugins screen shows only installed plugins. `--offline` disables registry calls for one run.
Every registry call goes through `kiosk.Client`, which uses `fetchguard` with a 10 second timeout and a 4 MiB limit on the answer. Module paths, package paths and versions from the registry are checked (a plain module path, a strict `vX.Y.Z` version) before any `go` command runs.
## Admin Plugins screen
The admin's Settings → System → Plugins screen shows administrators holding `backend.view_plugins` (`cabana.PermissionViewPlugins`) what the application was built with and what the registry offers. The screen changes nothing: plugins are installed and updated with the commands above, followed by `summer build` and a deploy of the new binary, and the screen shows those commands with a copy button. See [Admin SPA](../backend/admin-spa.md#plugins-screen).
The Installed tab reads `party.Installed` through `GET <prefix>/api/v1/plugins` (`cabana.PluginsOverview`) and lists every plugin with its name, description, version and source. An application built before this metadata existed lists only the plugin IDs. For each plugin with a version the server asks the registry for its releases and applies the rule `summer plugin:update` uses: the newest release whose framework minimum the built framework version satisfies (`kiosk.NewestCompatible`) is an update when it is newer than the installed version (`kiosk.Newer`): a submodule version such as `v0.1.1-3-gabc1234` compares by its base tag (`kiosk.BaseVersion`), and a module at a pseudo-version by the release it builds on. Such a plugin gets an update badge and the command `summer plugin:update <id>`. A newer release that needs a newer framework is named with the framework version it needs. A plugin the registry does not list, and a plugin that is part of the application (no version), gets no badge.
The Marketplace tab searches the registry through `GET <prefix>/api/v1/plugins/marketplace?q=&category=` (`cabana.MarketplaceResponse`): the search text (at most 100 characters) and the category are passed on to the registry. Each result shows its name, description, newest version and icon, a marker when the application already has the plugin or when the built framework is too old for its newest release, and the command `summer plugin:install <id>` with a copy button. There is no install button.
The SPA never calls the registry: cabana does, through `kiosk.Client`, with a 4 second timeout per call, at most four version lookups at a time, and an in-process cache (searches for 5 minutes, a plugin's releases for 15 minutes, failures for 1 minute). When the registry does not answer in time or answers with an error, the Installed tab still lists every plugin, without update information, under a notice that the registry is unavailable, and the Marketplace tab shows the same notice. With `plugins.registry_url` set to an empty value no request leaves the process, the Marketplace tab and the update badges are hidden, and the Installed tab works as before.
## Registry API v1
summercms.io publishes the plugins it lists through a read-only JSON API. The marketplace pages, `summer` and the admin's Plugins screen read it. Its base path is `/api/registry/v1` (`kiosk.RegistryAPIPath`), and the `kiosk` module holds its wire types, so every client and the server share one definition of the shape.
| Request | Answer |
|---------|--------|
| `GET /api/registry/v1/plugins?q=&category=` | `{"data": [plugin, ...], "meta": {"total": N}}` (`kiosk.ListResponse`). `q` searches ID, name and description; `category` filters. |
| `GET /api/registry/v1/plugins/{id}` | `{"data": detail}` (`kiosk.DetailResponse`). |
| `GET /api/registry/v1/plugins/{id}/versions` | `{"data": [version, ...]}`, newest first (`kiosk.VersionsResponse`). |
| `POST /api/registry/v1/ingest` | Authenticated with an API token that has the `registry` scope; body `{"repository": "<https URL>"}` (`kiosk.IngestRequest`). Answers 202 with `{"data": {"repository": "...", "queued": true}}` (`kiosk.IngestResponse`). |
A plugin in a list (`kiosk.RegistryPlugin`) has these fields:
| Field | Meaning |
|-------|---------|
| `id`, `name`, `description` | From the latest release's `plugin.yaml`. |
| `module`, `package` | What `go get` and `summer.yaml` take. |
| `repository` | The HTTPS clone URL. |
| `category`, `featured`, `icon_url` | Presentation fields edited on summercms.io. |
| `latest_version`, `latest_framework` | The newest tag and its framework minimum. |
| `updated_at` | When the listing last changed, RFC 3339. |
The detail (`kiosk.RegistryPluginDetail`) adds `readme_markdown`, `readme_html`, `screenshots` (a list of `url` and `caption`) and `requires` of the latest release. A version (`kiosk.RegistryVersion`) has `version`, `framework`, `requires`, `module`, `package`, `commit` and `tagged_at`.
Errors answer with `{"error": {"code": "...", "message": "..."}}` (`kiosk.ErrorResponse`): 404 with `not_found` for an unknown ID, 400 with `invalid_query` for a bad parameter.
Compatibility rules:
- Every answer is an envelope object (`data`, `meta` or `error`), never a bare array.
- Clients ignore fields they do not know, and v1 changes only by adding fields. A change that removes or renames a field needs a `/api/registry/v2` path.
- The GET endpoints need no credentials, allow any origin (CORS) and answer with `Cache-Control: public, max-age=300` and an ETag.
- Versions are strict `vX.Y.Z` tags (`kiosk.ValidVersion`) compared numerically (`kiosk.CompareVersions`). A client picks the release to install with `kiosk.NewestCompatible`: the newest one whose framework minimum the application's framework version satisfies (`kiosk.Satisfies`).