feat(kiosk): plugin.yaml manifest and summer plugin:manifest

- new module kiosk: Manifest, Marshal, Parse, Derive, Generate, Write, Check, StaleError
- plugin:manifest [dir] with --check, --pretend, --name, --description
- docs: kiosk README, root table row, plugins/marketplace.md, scaffolding row
This commit is contained in:
Jakub Zych
2026-10-29 16:55:17 +01:00
parent 824e3d5882
commit ec6a1955fc
14 changed files with 979 additions and 1 deletions

View File

@@ -105,6 +105,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. | A summer festival announces events and the crowd reacts; it is the event bus. |
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. | A lifeguard for outbound fetches; it watches every request the application sends out and stops the dangerous ones. |
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. | A signal flare reaches people far away; it sends Web Push notifications to browsers. |
| [kiosk](modules/kiosk/README.md) | Plugin manifests and the plugin registry contract: generates and checks each plugin's plugin.yaml from its own sources. | 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. |
| [lagoon](modules/lagoon/README.md) | Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. | Calm, deep water where everything settles; it is the database layer where data is stored. |
| [lighthouse](modules/lighthouse/README.md) | Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. | Beams a signal out to everyone watching; it broadcasts realtime messages to subscribers. |
| [pact](modules/pact/README.md) | Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. | An agreement both sides keep; it holds the interfaces a plugin agrees to implement. |

View File

@@ -3,9 +3,11 @@ package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
@@ -13,6 +15,7 @@ import (
"git.golem15.com/golem15/summercms/internal/dev"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/kiosk"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
@@ -38,6 +41,7 @@ func toolCommands() []bonfire.Command {
makeJobCommand(),
makeAdminControllerCommand(),
addPluginCommand(),
pluginManifestCommand(),
newCommand(),
devCommand(),
parityProxyCommand(),
@@ -414,6 +418,92 @@ already registered is an error.`,
}
}
func pluginManifestCommand() bonfire.Command {
return bonfire.Command{
Name: "plugin:manifest",
Description: "Generate or check a plugin's plugin.yaml from its sources",
Long: `Writes plugin.yaml into a plugin directory (the current one by default). Every
field comes from the plugin's own sources: the ID and the required plugins
from what plugin.go's ID() and Requires() return (string literals or
constants, read without running any code), the module path and the framework
and plugin 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. plugin.yaml is committed next to plugin.go and never
edited by hand: rerun the command after changing any of those sources.
--name and --description replace the README text and are kept in plugin.yaml
until changed again; --name="" clears the override. A README whose H1 is the
plugin ID needs --name. --pretend prints the file without writing it.
--check writes nothing and exits 1, printing the expected content, when
plugin.yaml is missing or differs from what the command would write.`,
Examples: []bonfire.Example{
{Command: "plugin:manifest plugins/acme/blog", Description: "Write plugins/acme/blog/plugin.yaml"},
{Command: "plugin:manifest --check", Description: "Fail when the current directory's plugin.yaml is missing or stale"},
{Command: "plugin:manifest --pretend", Description: "Print the manifest without writing it"},
{Command: `plugin:manifest --name="Blog"`, Description: "Use Blog as the display name instead of the README's H1"},
},
Args: []bonfire.Arg{{
Name: "dir",
Description: "Plugin directory, the one with plugin.go (default: the current directory)",
}},
Flags: []bonfire.Flag{
{Name: "check", Description: "Write nothing; exit 1 when plugin.yaml is missing or stale", Bare: true},
{Name: "pretend", Description: "Print the generated plugin.yaml instead of writing it", Bare: true},
{Name: "name", Description: "Display name override, kept in plugin.yaml; an empty value clears it"},
{Name: "description", Description: "Description override, kept in plugin.yaml; an empty value clears it"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
dir, ok := in.Argument("dir")
if !ok || dir == "" {
dir = "."
}
var opts kiosk.DeriveOptions
if v, ok := in.Flag("name"); ok {
opts.Name = &v
}
if v, ok := in.Flag("description"); ok {
opts.Description = &v
}
if bonfire.FlagBool(in, "check") {
if opts.Name != nil || opts.Description != nil {
return fmt.Errorf("plugin:manifest --check takes no --name or --description; run plugin:manifest without --check to change them")
}
err := kiosk.Check(ctx, dir)
var stale *kiosk.StaleError
if errors.As(err, &stale) {
out.Println("Expected " + stale.Path + ":")
out.Println(strings.TrimRight(string(stale.Want), "\n"))
}
if err != nil {
return err
}
out.Success(filepath.Join(dir, kiosk.FileName) + " is up to date")
return nil
}
if bonfire.FlagBool(in, "pretend") {
data, warnings, err := kiosk.Generate(ctx, dir, opts)
for _, w := range warnings {
out.Warning(w)
}
if err != nil {
return err
}
out.Println(strings.TrimRight(string(data), "\n"))
return nil
}
path, warnings, err := kiosk.Write(ctx, dir, opts)
for _, w := range warnings {
out.Warning(w)
}
if err != nil {
return err
}
out.Success("Wrote " + path)
return nil
},
}
}
func newCommand() bonfire.Command {
return bonfire.Command{
Name: "new",

View File

@@ -29,7 +29,7 @@ Each framework module is one Go package under `modules/`, documented by its READ
| Data | [lagoon](../../modules/lagoon/README.md), [beachcomber](../../modules/beachcomber/README.md) |
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md), [seaglass](../../modules/seaglass/README.md) |
| Services | [cooler](../../modules/cooler/README.md), [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) |
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md), [sunrise](../../modules/sunrise/README.md) |
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md), [sunrise](../../modules/sunrise/README.md), [kiosk](../../modules/kiosk/README.md) |
| Utilities | [sieve](../../modules/sieve/README.md), [breakwater](../../modules/breakwater/README.md) |
## What runs where

View File

@@ -103,6 +103,13 @@ 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`.
## Plugin commands
| Command | Purpose |
|---------|---------|
| `summer plugin:add` | Registers a local plugin directory: `summer.yaml`, a `require` and a local `replace` in `go.mod`, and `go.work`. |
| `summer plugin:manifest` | Writes the plugin's `plugin.yaml` from its sources (`plugin.go`, `go.mod`, the README). `--pretend` prints it, `--check` exits 1 when the committed file is missing or stale, `--name` and `--description` override the README text. See [Publishing plugins](../plugins/marketplace.md). |
## Generating plugin parts
The other `make:` commands add one artifact to an existing plugin. Each takes the plugin ID and an exported Go name:

View File

@@ -0,0 +1,74 @@
---
title: Publishing plugins
description: Describe a plugin for the marketplace with a generated plugin.yaml, keep it current with summer plugin:manifest --check, 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. |
| `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, with the version `go.mod` requires its module at. 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.

84
modules/kiosk/README.md Normal file
View File

@@ -0,0 +1,84 @@
# 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.
- 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.
- Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps.
- `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.
## 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
}
```
## 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` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. |
## 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. |
## Dependencies
- SummerCMS modules: none.
- 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`, `os`, `os/exec`, `path/filepath`, `regexp`, `sort`, `strconv`, `strings`.
- Tools: the `go` command (`go mod edit -json`), run without a shell.
## Testing
```sh
go test ./modules/kiosk/...
```
`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests. The tests run the local `go` command but need no network and no database.

91
modules/kiosk/check.go Normal file
View File

@@ -0,0 +1,91 @@
package kiosk
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
)
// Generate derives the manifest of the plugin in dir and returns the
// plugin.yaml bytes and any warnings, without writing anything.
func Generate(ctx context.Context, dir string, opts DeriveOptions) ([]byte, []string, error) {
m, warnings, err := Derive(ctx, dir, opts)
if err != nil {
return nil, warnings, err
}
out, err := Marshal(m)
if err != nil {
return nil, warnings, err
}
return out, warnings, nil
}
// Write generates dir's plugin.yaml and writes it, returning its path.
func Write(ctx context.Context, dir string, opts DeriveOptions) (path string, warnings []string, err error) {
out, warnings, err := Generate(ctx, dir, opts)
if err != nil {
return "", warnings, err
}
path = filepath.Join(dir, FileName)
if err := os.WriteFile(path, out, 0o644); err != nil {
return "", warnings, fmt.Errorf("kiosk: write %s: %w", path, err)
}
return path, warnings, nil
}
// StaleError reports a plugin.yaml that is missing or differs from what
// summer plugin:manifest would write now.
type StaleError struct {
// Path is the plugin.yaml path.
Path string
// Missing is true when the file does not exist.
Missing bool
// Want is the freshly generated content; Got is the committed one.
Want, Got []byte
}
// Error names the path and, for a stale file, the first differing line.
func (e *StaleError) Error() string {
if e.Missing {
return fmt.Sprintf("kiosk: %s is missing; generate it with summer plugin:manifest", e.Path)
}
want := bytes.Split(e.Want, []byte("\n"))
got := bytes.Split(e.Got, []byte("\n"))
for i := 0; i < len(want) || i < len(got); i++ {
var w, g []byte
if i < len(want) {
w = want[i]
}
if i < len(got) {
g = got[i]
}
if !bytes.Equal(w, g) {
return fmt.Sprintf("kiosk: %s is stale: line %d is %q, want %q", e.Path, i+1, g, w)
}
}
return fmt.Sprintf("kiosk: %s is stale", e.Path)
}
// Check regenerates dir's plugin.yaml in memory, keeping its overrides,
// and returns a *StaleError when the file on disk is missing or differs.
func Check(ctx context.Context, dir string) error {
want, _, err := Generate(ctx, dir, DeriveOptions{})
if err != nil {
return err
}
path := filepath.Join(dir, FileName)
got, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return &StaleError{Path: path, Missing: true, Want: want}
}
if err != nil {
return fmt.Errorf("kiosk: read %s: %w", path, err)
}
if !bytes.Equal(want, got) {
return &StaleError{Path: path, Want: want, Got: got}
}
return nil
}

368
modules/kiosk/derive.go Normal file
View File

@@ -0,0 +1,368 @@
package kiosk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
)
// DeriveOptions changes the persisted name and description overrides. A
// nil field keeps the override already in plugin.yaml; a pointer to ""
// clears it; any other value sets it.
type DeriveOptions struct {
Name *string
Description *string
}
// goMod is the part of `go mod edit -json` output Derive reads.
type goMod struct {
Module struct {
Path string
}
Require []goModRequire
}
type goModRequire struct {
Path string
Version string
Indirect bool
}
// version returns the go.mod require version of path, or "".
func (m goMod) version(path string) (string, bool) {
for _, r := range m.Require {
if r.Path == path {
return r.Version, true
}
}
return "", false
}
var pseudoVersionRe = regexp.MustCompile(`^v\d+\.\d+\.\d+-(?:[0-9A-Za-z.-]+\.)?\d{14}-[0-9a-f]{12}$`)
// isPseudoVersion reports whether v is a Go pseudo-version.
func isPseudoVersion(v string) bool { return pseudoVersionRe.MatchString(v) }
// Derive builds the manifest of the plugin in dir from its sources: the
// module and the version minimums from the nearest go.mod, the ID and
// requirements from plugin.go, the name and description from README.md.
// Overrides come from an existing plugin.yaml in dir, changed by opts.
// No plugin code is compiled or run. The returned strings are warnings.
func Derive(ctx context.Context, dir string, opts DeriveOptions) (Manifest, []string, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return Manifest{}, nil, fmt.Errorf("kiosk: resolve %s: %w", dir, err)
}
if st, err := os.Stat(abs); err != nil || !st.IsDir() {
return Manifest{}, nil, fmt.Errorf("kiosk: %s is not a plugin directory", dir)
}
root, err := moduleRoot(abs)
if err != nil {
return Manifest{}, nil, err
}
mod, err := readGoMod(ctx, root)
if err != nil {
return Manifest{}, nil, err
}
var warnings []string
m := Manifest{Schema: SchemaVersion, Module: mod.Module.Path}
m.Package, err = packagePath(mod.Module.Path, root, abs)
if err != nil {
return Manifest{}, nil, err
}
fw, ok := mod.version(FrameworkModule)
switch {
case !ok:
return Manifest{}, nil, fmt.Errorf("kiosk: %s does not require %s; the framework require is the plugin's framework minimum", filepath.Join(root, "go.mod"), FrameworkModule)
case fw == "v0.0.0":
return Manifest{}, nil, fmt.Errorf("kiosk: %s requires %s v0.0.0, which is not a released version; require a framework tag", filepath.Join(root, "go.mod"), FrameworkModule)
case isPseudoVersion(fw):
warnings = append(warnings, fmt.Sprintf("framework minimum %s is a pseudo-version; require a framework tag before publishing", fw))
}
m.Framework = fw
ident, err := readLiteralIdentity(abs)
if err != nil {
return Manifest{}, nil, err
}
m.ID = ident.ID
if len(ident.Requires) > 0 {
m.Requires = Requires{}
for _, id := range ident.Requires {
m.Requires[id] = ""
}
}
existing, err := readExisting(abs)
if err != nil {
return Manifest{}, nil, err
}
ov := Overrides{}
if existing.Overrides != nil {
ov = *existing.Overrides
}
if opts.Name != nil {
ov.Name = strings.TrimSpace(*opts.Name)
}
if opts.Description != nil {
ov.Description = strings.TrimSpace(*opts.Description)
}
if ov != (Overrides{}) {
m.Overrides = &ov
}
h1, summary, err := readReadme(filepath.Join(abs, "README.md"))
if err != nil {
return Manifest{}, nil, err
}
m.Name = h1
if ov.Name != "" {
m.Name = ov.Name
} else if h1 == m.ID {
return Manifest{}, nil, fmt.Errorf("kiosk: README.md's H1 is the plugin ID %s, not a display name; pass --name=\"…\" (it is kept in %s)", m.ID, FileName)
}
m.Description = summary
if ov.Description != "" {
m.Description = ov.Description
}
if m.Description == "" {
return Manifest{}, nil, fmt.Errorf("kiosk: README.md has no summary paragraph after its H1; add the one-sentence summary or pass --description=\"…\"")
}
return m, warnings, nil
}
// moduleRoot walks up from dir to the nearest directory with a go.mod.
func moduleRoot(dir string) (string, error) {
for d := dir; ; {
if st, err := os.Stat(filepath.Join(d, "go.mod")); err == nil && !st.IsDir() {
return d, nil
}
parent := filepath.Dir(d)
if parent == d {
return "", fmt.Errorf("kiosk: no go.mod in %s or above", dir)
}
d = parent
}
}
// packagePath is the import path of dir inside the module rooted at root.
func packagePath(modPath, root, dir string) (string, error) {
rel, err := filepath.Rel(root, dir)
if err != nil {
return "", fmt.Errorf("kiosk: package path: %w", err)
}
if rel == "." {
return modPath, nil
}
return modPath + "/" + filepath.ToSlash(rel), nil
}
// readGoMod runs `go mod edit -json` in root.
func readGoMod(ctx context.Context, root string) (goMod, error) {
out, err := runGo(ctx, root, "mod", "edit", "-json")
if err != nil {
return goMod{}, err
}
var m goMod
if err := json.Unmarshal(out, &m); err != nil {
return goMod{}, fmt.Errorf("kiosk: read %s: %w", filepath.Join(root, "go.mod"), err)
}
if m.Module.Path == "" {
return goMod{}, fmt.Errorf("kiosk: %s has no module path", filepath.Join(root, "go.mod"))
}
return m, nil
}
// runGo runs the go command in dir without a shell and returns stdout.
// Workspaces are off: a plugin manifest describes the module on its own.
func runGo(ctx context.Context, dir string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
env := os.Environ()
filtered := make([]string, 0, len(env)+1)
for _, e := range env {
if !strings.HasPrefix(e, "GOWORK=") {
filtered = append(filtered, e)
}
}
cmd.Env = append(filtered, "GOWORK=off")
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("kiosk: go %s in %s: %w\n%s", strings.Join(args, " "), dir, err, bytes.TrimSpace(stderr.Bytes()))
}
return out, nil
}
// readExisting parses dir's plugin.yaml, or returns the zero manifest when
// there is none.
func readExisting(dir string) (Manifest, error) {
raw, err := os.ReadFile(filepath.Join(dir, FileName))
if errors.Is(err, os.ErrNotExist) {
return Manifest{}, nil
}
if err != nil {
return Manifest{}, fmt.Errorf("kiosk: read %s: %w", filepath.Join(dir, FileName), err)
}
m, err := Parse(raw)
if err != nil {
return Manifest{}, fmt.Errorf("%w\n(delete %s and rerun summer plugin:manifest; pass --name/--description again if it had overrides)", err, filepath.Join(dir, FileName))
}
return m, nil
}
// readReadme returns the plain text of the README's first H1 and of the
// first paragraph after it, whitespace collapsed.
func readReadme(path string) (name, summary string, err error) {
src, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("kiosk: the plugin needs a README.md whose H1 is the plugin name: %w", err)
}
doc := goldmark.New().Parser().Parse(text.NewReader(src))
var h1 gast.Node
for n := doc.FirstChild(); n != nil; n = n.NextSibling() {
if h, ok := n.(*gast.Heading); ok && h.Level == 1 {
h1 = h
break
}
}
if h1 == nil {
return "", "", fmt.Errorf("kiosk: %s has no H1; its first-level heading is the plugin name", path)
}
name = plainText(h1, src)
if name == "" {
return "", "", fmt.Errorf("kiosk: %s has an empty H1", path)
}
for n := h1.NextSibling(); n != nil; n = n.NextSibling() {
if p, ok := n.(*gast.Paragraph); ok {
summary = plainText(p, src)
break
}
if _, ok := n.(*gast.Heading); ok {
break
}
}
return name, summary, nil
}
// plainText is the text content of n with whitespace collapsed: code
// spans and link texts keep their text, markup is dropped.
func plainText(n gast.Node, src []byte) string {
var b strings.Builder
_ = gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
switch t := c.(type) {
case *gast.Text:
b.Write(t.Segment.Value(src))
if t.SoftLineBreak() || t.HardLineBreak() {
b.WriteByte(' ')
}
case *gast.String:
b.Write(t.Value)
case *gast.AutoLink:
b.Write(t.Label(src))
return gast.WalkSkipChildren, nil
case *gast.RawHTML:
return gast.WalkSkipChildren, nil
}
return gast.WalkContinue, nil
})
return strings.Join(strings.Fields(b.String()), " ")
}
// identity is what Task 2's reader returns: the literals of ID() and
// Requires().
type identity struct {
ID string
Requires []string
}
// readLiteralIdentity reads the string literals returned by ID() and
// Requires() in dir/plugin.go.
func readLiteralIdentity(dir string) (identity, error) {
path := filepath.Join(dir, "plugin.go")
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return identity{}, fmt.Errorf("kiosk: parse %s: %w", path, err)
}
var id identity
var idErr, reqErr error
foundID := false
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv == nil || fn.Body == nil {
continue
}
ret := lastReturn(fn)
switch fn.Name.Name {
case "ID":
foundID = true
lit, ok := ret.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
idErr = fmt.Errorf("kiosk: %s: ID() must return a string literal", path)
continue
}
id.ID, _ = strconv.Unquote(lit.Value)
case "Requires":
switch e := ret.(type) {
case *ast.Ident:
if e.Name != "nil" {
reqErr = fmt.Errorf("kiosk: %s: Requires() must return nil or a []string literal", path)
}
case *ast.CompositeLit:
for _, el := range e.Elts {
lit, ok := el.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
reqErr = fmt.Errorf("kiosk: %s: Requires() must return string literals", path)
break
}
s, _ := strconv.Unquote(lit.Value)
id.Requires = append(id.Requires, s)
}
default:
reqErr = fmt.Errorf("kiosk: %s: Requires() must return nil or a []string literal", path)
}
}
}
if !foundID {
return identity{}, fmt.Errorf("kiosk: %s has no ID() method", path)
}
if idErr != nil {
return identity{}, idErr
}
if reqErr != nil {
return identity{}, reqErr
}
return id, nil
}
// lastReturn is the single result of fn's last top-level return, or nil.
func lastReturn(fn *ast.FuncDecl) ast.Expr {
var res ast.Expr
for _, stmt := range fn.Body.List {
if ret, ok := stmt.(*ast.ReturnStmt); ok && len(ret.Results) == 1 {
res = ret.Results[0]
}
}
return res
}

23
modules/kiosk/kiosk.go Normal file
View File

@@ -0,0 +1,23 @@
// Package kiosk reads and writes plugin manifests (plugin.yaml) and holds
// the plugin registry contract shared by the summer tool, the registry
// server and the admin.
//
// A plugin.yaml is generated from the plugin's own sources by summer
// plugin:manifest: the ID and required plugins from plugin.go, the module
// path and version minimums from go.mod, the name and description from the
// README. It is committed next to plugin.go and never edited by hand; Check
// and CheckT report a stale copy.
package kiosk
const (
// FileName is the manifest file name, one per plugin package directory.
FileName = "plugin.yaml"
// SchemaVersion is the newest plugin.yaml schema this package reads
// and the one it writes.
SchemaVersion = 1
// FrameworkModule is the framework's module path; its go.mod require
// is a plugin's framework minimum.
FrameworkModule = "git.golem15.com/golem15/summercms"
// Header is the comment line every generated plugin.yaml starts with.
Header = "# Generated by summer plugin:manifest. Do not edit; rerun the command."
)

90
modules/kiosk/manifest.go Normal file
View File

@@ -0,0 +1,90 @@
package kiosk
import (
"bytes"
"fmt"
"sort"
"github.com/goccy/go-yaml"
)
// Manifest is one plugin.yaml. Fields are written in declaration order.
type Manifest struct {
// Schema is the plugin.yaml format version (SchemaVersion when written).
Schema int `yaml:"schema"`
// ID is the plugin ID that plugin.go's ID() returns.
ID string `yaml:"id"`
// Module is the go.mod module path: what go get takes.
Module string `yaml:"module"`
// Package is the plugin's import path: what summer.yaml lists. It
// differs from Module for a plugin in a subdirectory of its module.
Package string `yaml:"package"`
// Name is the display name: the README's H1 or the name override.
Name string `yaml:"name"`
// Description is the README's one-sentence summary or the override.
Description string `yaml:"description"`
// Framework is the minimum framework version: the go.mod require of
// FrameworkModule.
Framework string `yaml:"framework"`
// Requires maps each plugin ID that Requires() returns to its minimum
// version; empty when the plugin ships in the same module or its
// module is not a go.mod require.
Requires Requires `yaml:"requires,omitempty"`
// Overrides holds the --name and --description values; nil when
// neither is set.
Overrides *Overrides `yaml:"overrides,omitempty"`
}
// Overrides are the name and description set with summer plugin:manifest
// --name and --description. They survive regeneration until cleared.
type Overrides struct {
Name string `yaml:"name,omitempty"`
Description string `yaml:"description,omitempty"`
}
// Requires maps a required plugin ID to its minimum version. It marshals
// with the IDs sorted, so the generated bytes are stable.
type Requires map[string]string
// MarshalYAML emits the map as a sorted mapping.
func (r Requires) MarshalYAML() (any, error) {
ids := make([]string, 0, len(r))
for id := range r {
ids = append(ids, id)
}
sort.Strings(ids)
out := make(yaml.MapSlice, 0, len(ids))
for _, id := range ids {
out = append(out, yaml.MapItem{Key: id, Value: r[id]})
}
return out, nil
}
// Marshal returns the plugin.yaml bytes: the Header line, then the fields.
func Marshal(m Manifest) ([]byte, error) {
if m.Overrides != nil && *m.Overrides == (Overrides{}) {
m.Overrides = nil
}
body, err := yaml.Marshal(m)
if err != nil {
return nil, fmt.Errorf("kiosk: marshal %s: %w", FileName, err)
}
var b bytes.Buffer
b.WriteString(Header)
b.WriteByte('\n')
b.Write(body)
return b.Bytes(), nil
}
// Parse reads plugin.yaml bytes. A schema newer than SchemaVersion is an
// error that says to update summer; unknown fields are ignored.
func Parse(data []byte) (Manifest, error) {
var m Manifest
if err := yaml.Unmarshal(data, &m); err != nil {
return Manifest{}, fmt.Errorf("kiosk: parse %s: %w", FileName, err)
}
if m.Schema > SchemaVersion {
return Manifest{}, fmt.Errorf("kiosk: %s has schema %d, this summer reads up to %d; update summer", FileName, m.Schema, SchemaVersion)
}
return m, nil
}

View File

@@ -0,0 +1,126 @@
package kiosk
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
// copyTestdata copies testdata/<names...> into a fresh temp dir, keeping
// the relative layout, and returns the temp dir.
func copyTestdata(t *testing.T, names ...string) string {
t.Helper()
dst := t.TempDir()
for _, name := range names {
src := filepath.Join("testdata", name)
err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel("testdata", path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
t.Fatalf("copy %s: %v", src, err)
}
}
return dst
}
// TestManifestSmoke drives plugin.yaml generation end to end on the
// fixture plugins in testdata.
func TestManifestSmoke(t *testing.T) {
ctx := context.Background()
t.Run("literal", func(t *testing.T) {
dir := filepath.Join(copyTestdata(t, "literal"), "literal")
path, warnings, err := Write(ctx, dir, DeriveOptions{})
if err != nil {
t.Fatalf("Write: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings = %v", warnings)
}
if path != filepath.Join(dir, FileName) {
t.Errorf("path = %s", path)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(raw), Header+"\n") {
t.Errorf("plugin.yaml does not start with the header:\n%s", raw)
}
m, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := Manifest{
Schema: SchemaVersion,
ID: "acme.blog",
Module: "example.test/sm-acme-plugin",
Package: "example.test/sm-acme-plugin",
Name: "Blog",
Description: "Posts and categories for an acme application, with a feed.",
Framework: "v0.5.3",
}
if m.Schema != want.Schema || m.ID != want.ID || m.Module != want.Module || m.Package != want.Package ||
m.Name != want.Name || m.Description != want.Description || m.Framework != want.Framework ||
len(m.Requires) != 0 || m.Overrides != nil {
t.Errorf("manifest = %+v, want %+v", m, want)
}
wantOrder := []string{"schema:", "id:", "module:", "package:", "name:", "description:", "framework:"}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")[1:]
if len(lines) != len(wantOrder) {
t.Fatalf("lines = %q", lines)
}
for i, prefix := range wantOrder {
if !strings.HasPrefix(lines[i], prefix) {
t.Errorf("line %d = %q, want prefix %q", i+2, lines[i], prefix)
}
}
if err := Check(ctx, dir); err != nil {
t.Fatalf("Check on a fresh plugin.yaml: %v", err)
}
readme := filepath.Join(dir, "README.md")
src, _ := os.ReadFile(readme)
edited := strings.Replace(string(src), "with a feed.", "with an Atom feed.", 1)
if err := os.WriteFile(readme, []byte(edited), 0o644); err != nil {
t.Fatal(err)
}
var stale *StaleError
if err := Check(ctx, dir); !errors.As(err, &stale) || stale.Missing {
t.Fatalf("Check after a README edit = %v, want a stale *StaleError", err)
}
if !strings.Contains(stale.Error(), path) || !strings.Contains(stale.Error(), "line ") {
t.Errorf("StaleError text = %q", stale.Error())
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := Check(ctx, dir); !errors.As(err, &stale) || !stale.Missing {
t.Fatalf("Check without plugin.yaml = %v, want Missing", err)
}
if len(stale.Want) == 0 {
t.Error("a missing StaleError carries no Want bytes")
}
})
}

View File

@@ -0,0 +1,7 @@
# Blog
Posts and categories for an `acme` application, with a feed.
## Overview
More text that is not part of the manifest.

5
modules/kiosk/testdata/literal/go.mod vendored Normal file
View File

@@ -0,0 +1,5 @@
module example.test/sm-acme-plugin
go 1.27.0
require git.golem15.com/golem15/summercms v0.5.3

View File

@@ -0,0 +1,12 @@
// Package blog is a test fixture: a plugin whose ID() and Requires()
// return literals. kiosk reads it with go/parser; it is never built.
package blog
// Plugin is the fixture plugin.
type Plugin struct{}
// ID returns the plugin ID.
func (*Plugin) ID() string { return "acme.blog" }
// Requires returns no plugins.
func (*Plugin) Requires() []string { return nil }