Files
summercms/modules/kiosk/manifest.go
Jakub Zych 51ec2f9daa feat(kiosk): resolve constant plugin IDs, overrides and the CheckT helper
- kiosk.ReadIdentity: ID()/Requires() as literals or constants across packages and modules, read with go/parser, never run
- plugin minimums from go.mod requires; v0.0.0 is an error, a pseudo-version warns
- kiosk.Validate, kiosk.CheckT (no testing import)
- plugin:add reads IDs through kiosk.ReadIdentity, so const-ID plugins register
2026-10-29 17:02:38 +01:00

164 lines
5.3 KiB
Go

package kiosk
import (
"bytes"
"fmt"
"regexp"
"sort"
"strings"
"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)
}
if err := Validate(m); err != nil {
return Manifest{}, err
}
return m, nil
}
var (
pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
releaseVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
)
// Validate checks a manifest: the schema is between 1 and SchemaVersion,
// the ID and every required ID are lowercase vendor.plugin, module and
// package are plain module paths (package inside module), name and
// description are set, and the framework and every non-empty plugin
// minimum is a vX.Y.Z release or a Go pseudo-version.
func Validate(m Manifest) error {
if m.Schema < 1 || m.Schema > SchemaVersion {
return fmt.Errorf("kiosk: schema %d is not between 1 and %d", m.Schema, SchemaVersion)
}
if !pluginIDRe.MatchString(m.ID) {
return fmt.Errorf("kiosk: id %q must be lowercase vendor.plugin", m.ID)
}
if err := validPath(m.Module); err != nil {
return fmt.Errorf("kiosk: module: %w", err)
}
if err := validPath(m.Package); err != nil {
return fmt.Errorf("kiosk: package: %w", err)
}
if m.Package != m.Module && !strings.HasPrefix(m.Package, m.Module+"/") {
return fmt.Errorf("kiosk: package %q is not inside module %q", m.Package, m.Module)
}
if strings.TrimSpace(m.Name) == "" {
return fmt.Errorf("kiosk: name is empty")
}
if strings.TrimSpace(m.Description) == "" {
return fmt.Errorf("kiosk: description is empty")
}
if !releaseOrPseudo(m.Framework) {
return fmt.Errorf("kiosk: framework %q must be a vX.Y.Z release", m.Framework)
}
for id, v := range m.Requires {
if !pluginIDRe.MatchString(id) {
return fmt.Errorf("kiosk: required id %q must be lowercase vendor.plugin", id)
}
if v != "" && !releaseOrPseudo(v) {
return fmt.Errorf("kiosk: minimum %q of %s must be a vX.Y.Z release", v, id)
}
}
return nil
}
func releaseOrPseudo(v string) bool {
return releaseVersion.MatchString(v) || isPseudoVersion(v)
}
// validPath accepts a slash-separated module or import path.
func validPath(p string) error {
switch {
case p == "":
return fmt.Errorf("is empty")
case strings.HasPrefix(p, "-"):
return fmt.Errorf("%q must not start with -", p)
case strings.ContainsAny(p, " \t\r\n\\\"'`$;&|*?<>()[]{}@"):
return fmt.Errorf("%q contains invalid characters", p)
}
for _, seg := range strings.Split(p, "/") {
if seg == "" || seg == "." || seg == ".." {
return fmt.Errorf("%q has an invalid path segment", p)
}
}
return nil
}