Files
summercms/modules/kiosk/README.md
Jakub Zych 87921c27f6 fix(kiosk): fetch a required plugin module whose replace directory is missing
A plugin developed inside an application replaces its required plugins
with sibling directories (../user). Checked out on its own, that
directory is missing and plugin.yaml lost the minimum (golem15.user: ""),
so the manifest differed between the two layouts and TestPluginManifest
could not pass in both. Such a module is now fetched at the version
go.mod requires, as a consumer would see it.

- listedModule.fallback marks a local replace whose directory is absent
- fallback modules download separately, so one unfetchable version does
  not hold up the others
- TestDeriveMissingReplace over a file:// proxy (offline)
2026-10-29 23:18:07 +01:00

177 lines
14 KiB
Markdown

# kiosk
Plugin manifests and the plugin registry contract: generates and checks each plugin's plugin.yaml from its own sources.
`import "git.golem15.com/golem15/summercms/modules/kiosk"`
**Name:** A beach kiosk where every plugin on the shelf carries its label; the label is printed from what is in the box, never written by hand.
## Overview
A plugin describes itself in `plugin.yaml`, committed next to its `plugin.go`. The file is generated: `summer plugin:manifest` runs `kiosk.Write`, which reads the plugin's sources and never runs its code. The ID and the required plugins come from what `ID()` and `Requires()` in `plugin.go` return, the module path and the version minimums from `go.mod`, the package import path from the directory, and the name and description from the README's H1 and the paragraph after it (the standard README structure). The registry on summercms.io reads the same file from every tagged release, and `summer build` bakes its name and description into the application.
WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `composer.json`; here it is derived once, so the copy in the repository cannot drift from the code.
## Features
- One `plugin.yaml` per plugin package directory, so a repository with a nested plugin carries two files.
- `kiosk.ReadIdentity` reads `ID()` and `Requires()` statically with `go/parser`: a string literal, a constant of the same package, of another package of the module, or of a package in a module `go.mod` requires. It never builds or runs plugin code, and `summer plugin:add` reads plugin IDs with it too.
- A schema version (`kiosk.SchemaVersion`) and a fixed header line (`kiosk.Header`); a manifest with a newer schema is refused with a hint to update summer.
- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. A requirement at `v0.0.0` is an error (not a released version); a pseudo-version is accepted with a warning. A required module replaced by a local directory that is not there (a plugin developed inside an application, checked out on its own) is fetched at its required version, so the manifest is the same in both layouts.
- Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps.
- The registry API v1 contract in Go: `kiosk.RegistryAPIPath` and the wire types (`kiosk.ListResponse`, `kiosk.DetailResponse`, `kiosk.VersionsResponse`, `kiosk.ErrorResponse`, `kiosk.IngestRequest`, `kiosk.IngestResponse`). The registry server encodes with them and the summer tool and the admin decode with them, so the JSON shape exists once.
- Strict version rules: `kiosk.ValidVersion` (only `vX.Y.Z`), `kiosk.CompareVersions`, `kiosk.BaseVersion` for `git describe` output, `kiosk.Satisfies` for Go-style minimums, `kiosk.NewestCompatible` to pick the release an application can install, and `kiosk.Newer` with `kiosk.Comparable` to tell whether a release is an upgrade for an installed version (a release, a `git describe` string or a pseudo-version), so an update never goes back.
- A registry client, `kiosk.Client`, for the summer tool and the admin: `kiosk.NewClient` builds it over a `fetchguard` client in trusted mode with a 4 MiB body cap, a 10 second timeout and no redirects, and `kiosk.RegistryURL` reads the registry origin from the `plugins.registry_url` config key (default `kiosk.DefaultRegistryURL`; an empty value disables registry calls).
- `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1, and `kiosk.CheckT` turns into a failing test in the plugin's own `go test`.
## Usage
```go
path, warnings, err := kiosk.Write(ctx, "plugins/acme/blog", kiosk.DeriveOptions{})
if err != nil {
return err
}
for _, w := range warnings {
log.Println(w)
}
fmt.Println("wrote", path)
if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil {
var stale *kiosk.StaleError
if errors.As(err, &stale) {
fmt.Printf("expected:\n%s", stale.Want)
}
return err
}
```
Picking the newest release of a plugin that an application on framework v0.6.2 can install:
```go
var versions kiosk.VersionsResponse
if err := json.Unmarshal(body, &versions); err != nil {
return err
}
v, ok := kiosk.NewestCompatible(versions.Data, "v0.6.2")
if !ok {
return errors.New("every release needs a newer framework")
}
fmt.Println("go get", v.Module+"@"+v.Version)
```
Asking the configured registry for a plugin's releases:
```go
client, err := kiosk.NewClient(kiosk.RegistryURL(app.Config), app.Config)
if errors.Is(err, kiosk.ErrRegistryDisabled) {
return nil // plugins.registry_url is empty: no registry calls
}
if err != nil {
return err
}
versions, err := client.Versions(ctx, "acme.blog")
if errors.Is(err, kiosk.ErrNotFound) {
return fmt.Errorf("acme.blog is not listed")
}
```
A plugin repository keeps its manifest current with one test:
```go
func TestPluginManifest(t *testing.T) {
kiosk.CheckT(t, ".")
}
```
## API reference
| Identifier | Description |
|------------|-------------|
| `kiosk.FileName` | The manifest file name, `plugin.yaml`. |
| `kiosk.SchemaVersion` | The newest schema this package reads, and the one it writes. |
| `kiosk.FrameworkModule` | The framework module path; its `go.mod` require is the plugin's framework minimum. |
| `kiosk.Header` | The comment line every generated `plugin.yaml` starts with. |
| `kiosk.Manifest` | One `plugin.yaml`: `Schema`, `ID`, `Module`, `Package`, `Name`, `Description`, `Framework`, `Requires`, `Overrides`, written in that order. |
| `kiosk.Requires` | Required plugin ID to minimum version; marshals sorted by ID. An empty minimum means the plugin ships in the same module or its module is not a `go.mod` require. |
| `kiosk.Requires.MarshalYAML` | Emits the map as a mapping sorted by ID. |
| `kiosk.Overrides` | The persisted `--name` and `--description` values. |
| `kiosk.Marshal` | The `plugin.yaml` bytes: the header line, then the fields. |
| `kiosk.Parse` | Reads `plugin.yaml` bytes; unknown fields are ignored, a newer schema is an error. |
| `kiosk.DeriveOptions` | Override changes for `kiosk.Derive`: nil keeps the persisted value, a pointer to `""` clears it. |
| `kiosk.Derive` | Builds the manifest of a plugin directory from its sources and returns it with warnings. |
| `kiosk.Generate` | `kiosk.Derive` plus `kiosk.Marshal`: the bytes `kiosk.Write` would write. |
| `kiosk.Write` | Generates and writes `<dir>/plugin.yaml` and returns its path. |
| `kiosk.Check` | Returns a `kiosk.StaleError` when `<dir>/plugin.yaml` is missing or differs from a fresh generation. |
| `kiosk.StaleError.Error` | Names the path, and for a stale file the first differing line. |
| `kiosk.TB` | The part of `testing.TB` that `kiosk.CheckT` calls (`Helper`, `Fatalf`); kiosk does not import `testing`. |
| `kiosk.CheckT` | Fails the test with the `kiosk.Check` error and the command to rerun when the manifest is missing or stale. |
| `kiosk.Validate` | Checks a manifest: schema, ID and required IDs, module and package paths (no leading `-`), name, description, and `vX.Y.Z` or pseudo-version minimums. `kiosk.Parse` and `kiosk.Generate` call it. |
| `kiosk.Requires.MarshalJSON` | Encodes the map as a JSON object, `{}` when empty, so the registry never sends `null` for requirements. |
| `kiosk.RegistryAPIPath` | `/api/registry/v1`, the base path of the registry API. |
| `kiosk.RegistryPlugin` | One listed plugin: `id`, `name`, `description`, `module`, `package`, `repository`, `category`, `featured`, `icon_url`, `latest_version`, `latest_framework`, `updated_at` (RFC 3339). |
| `kiosk.RegistryPluginDetail` | `kiosk.RegistryPlugin` plus `readme_markdown`, `readme_html`, `screenshots` and the `requires` of the latest version. |
| `kiosk.RegistryScreenshot` | One screenshot: `url`, `caption`. |
| `kiosk.RegistryVersion` | One tagged release: `version`, `framework`, `requires`, `module`, `package`, `commit`, `tagged_at`. |
| `kiosk.ListResponse` | `{"data": [...], "meta": {"total": N}}` for the plugin list. |
| `kiosk.ListMeta` | The `meta` object of a list: `total`. |
| `kiosk.DetailResponse` | `{"data": {...}}` for one plugin. |
| `kiosk.VersionsResponse` | `{"data": [...]}` for a plugin's releases, newest first. |
| `kiosk.ErrorResponse` | `{"error": {"code", "message"}}`, the body of every error. |
| `kiosk.RegistryError` | A stable `code` and a `message`. |
| `kiosk.RegistryErrorNotFound` | The `not_found` code of a 404. |
| `kiosk.RegistryErrorInvalidQuery` | The `invalid_query` code of a 400. |
| `kiosk.IngestRequest` | The body of the authenticated ingest request: `repository`. |
| `kiosk.IngestResponse` | The 202 answer to an ingest request: `{"data": {"repository", "queued"}}`. |
| `kiosk.IngestResult` | The `data` of an ingest answer. |
| `kiosk.ValidVersion` | Reports whether a version is a strict `vX.Y.Z` release (no pre-release, build metadata or leading zeros). |
| `kiosk.CompareVersions` | Compares two valid versions numerically per component: `v0.10.0` sorts after `v0.9.9`. |
| `kiosk.BaseVersion` | The `vX.Y.Z` tag a `git describe --tags` string starts from (`v0.3.1` for `v0.3.1-4-gabc1234`), or `""`. |
| `kiosk.Newer` | Whether a release is an upgrade for an installed version: above a release or the base tag of a `git describe` string, and for a pseudo-version at or above the release after the tag it builds on (`v0.4.1` and later for `v0.4.1-0.<timestamp>-<hash>`, never `v0.4.0`). Nothing is newer than a version `kiosk.Comparable` refuses. |
| `kiosk.Comparable` | Whether `kiosk.Newer` can place an installed version: a release, a `git describe` string, or a pseudo-version that builds on a tag (not `vX.0.0-<timestamp>-<hash>`, a bare hash or a pre-release). |
| `kiosk.Satisfies` | Whether a release meets a minimum; an empty minimum is always met, and a pseudo-version minimum is met by its base release and later. |
| `kiosk.NewestCompatible` | The newest release whose framework minimum a framework version satisfies. |
| `kiosk.DefaultRegistryURL` | `https://summercms.io`, the registry used when `plugins.registry_url` is not set. |
| `kiosk.RegistryURL` | The registry origin from a `compass.Config`: the `plugins.registry_url` value without a trailing slash, `kiosk.DefaultRegistryURL` when the key is absent, `""` (disabled) when it is set empty. |
| `kiosk.ErrRegistryDisabled` | Returned by `kiosk.NewClient` for an empty base URL. |
| `kiosk.ErrNotFound` | Wrapped by a `kiosk.Client` error when the registry answers 404; the message names the ID and the registry origin. |
| `kiosk.Client` | Calls the registry API v1 of one origin through `fetchguard` (trusted mode, 4 MiB cap, 10 s timeout, no redirects). |
| `kiosk.NewClient` | Builds a client for an `http` or `https` origin without user info, query or fragment; the config supplies fetchguard defaults and may be nil. |
| `kiosk.Client.BaseURL` | The origin the client calls. |
| `kiosk.Client.List` | `GET /plugins?q=&category=`, decoded as a `kiosk.ListResponse`. |
| `kiosk.Client.Plugin` | `GET /plugins/{id}`: one `kiosk.RegistryPluginDetail`. |
| `kiosk.Client.Versions` | `GET /plugins/{id}/versions`: the plugin's `kiosk.RegistryVersion` list, newest first. A non-2xx answer other than 404 is an error carrying the registry's error code; a redirect is an error asking for the final origin. |
| `kiosk.Identity` | The plugin ID and the required plugin IDs. |
| `kiosk.ReadIdentity` | Reads what `ID()` and `Requires()` return from the sources of a directory, resolving constants across packages and modules without running code. Any other expression is an error naming the method. |
| `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. |
## Configuration
| Key | Default | Meaning |
|-----|---------|---------|
| `plugins.registry_url` | `https://summercms.io` | Registry origin for `summer plugin:install`, `summer plugin:update` and the admin's Plugins screen (file `config/plugins.yaml`, env `SUMMER_PLUGINS__REGISTRY_URL`). It must be the final origin, because redirects are not followed. An empty value disables registry calls. |
## CLI commands
| Command | Purpose |
|---------|---------|
| `summer plugin:manifest [dir]` | Writes `plugin.yaml` for the plugin in `dir` (default: the current directory). `--pretend` prints it instead, `--check` fails when the committed file is missing or stale, `--name` and `--description` set or (with an empty value) clear the overrides. |
| `summer plugin:install <id\|git-url>` | Installs a plugin; reads its releases through `kiosk.Client` and picks one with `kiosk.NewestCompatible`. See [Installing plugins](../../docs/plugins/marketplace.md#installing-plugins). |
| `summer plugin:update [id]` | Moves installed plugins to their newest compatible release, read through `kiosk.Client`. See [Updating plugins](../../docs/plugins/marketplace.md#updating-plugins). |
## Dependencies
- SummerCMS modules: `compass` (the `plugins.registry_url` setting), `fetchguard` (registry HTTP calls).
- Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs).
- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `net/http`, `net/url`, `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`, `time`.
- Tools: the `go` command (`go mod edit -json`, `go list -m -json`, and `go mod download -json` for a required module not in the module cache), run without a shell and with workspaces off.
## Testing
```sh
go test ./modules/kiosk/...
```
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`, the registry JSON (golden bytes, unknown fields ignored) and the version rules. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database.
The registry client (`kiosk.Client`) is exercised by `TestInstallSmoke` in `internal/build`, against an `httptest` registry that serves these wire types.