Files
summercms/modules/kiosk/derive.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

301 lines
8.7 KiB
Go

package kiosk
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"go/ast"
"os"
"os/exec"
"path/filepath"
"regexp"
"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
r := newResolver(ctx, abs)
r.modLoaded, r.root, r.mod = true, root, mod
ident, err := r.identity(abs)
if err != nil {
return Manifest{}, nil, err
}
m.ID = ident.ID
reqs, w, err := r.minimums(ident.Requires)
warnings = append(warnings, w...)
if err != nil {
return Manifest{}, warnings, err
}
m.Requires = reqs
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()), " ")
}
// 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
}