Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a mechanical block/core/X -> block/pluginsdk/X import rewrite. - abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms authoring source (cms/backend/abi/proto/v1) with go_package retargeted to git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept cms/core proto duplication (audit gap 4). - abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1 save the embedded go_package path. - plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim, caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the guest-facing type packages: blocks (+builtin/shared/tags), templates (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai, subscriptions, menus, datasources. Internal imports rewritten core -> pluginsdk; zero block/core references remain. - README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one binding; no replace directives; templates/bn is a synced copy authored in cms. Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
151 lines
5.5 KiB
Go
151 lines
5.5 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
tomlpkg "github.com/BurntSushi/toml"
|
|
)
|
|
|
|
type ModFile struct {
|
|
Plugin ModPlugin `toml:"plugin"`
|
|
Compatibility *ModCompat `toml:"compatibility"`
|
|
Requires []ModRequirement `toml:"requires"`
|
|
}
|
|
|
|
type ModPlugin struct {
|
|
// Name is the lowercase identifier used for the plugin slug in URLs and DB
|
|
// lookups. The CLI normalises this on write; the registry normalises on
|
|
// create. Use DisplayName for human-readable presentation.
|
|
Name string `toml:"name"`
|
|
// DisplayName is the human-readable form (any case). Optional; if empty
|
|
// the registry falls back to the input name with its original case.
|
|
DisplayName string `toml:"display_name,omitempty"`
|
|
// Description is the short summary surfaced in the registry. Optional.
|
|
Description string `toml:"description,omitempty"`
|
|
// Scope is the plugin owner namespace as it appears in plugin.mod. It may
|
|
// include the leading "@" (e.g. "@themes") or omit it (e.g. "themes") —
|
|
// both forms are accepted. Consumers comparing scopes should trim the "@"
|
|
// before comparing; use ModFile.Coords() for a normalised display string.
|
|
Scope string `toml:"scope"`
|
|
Version string `toml:"version"`
|
|
Kind string `toml:"kind,omitempty"`
|
|
Categories []string `toml:"categories,omitempty"`
|
|
Tags []string `toml:"tags,omitempty"`
|
|
// RequiredIconPacks names icon-pack slugs the host CMS must ensure are
|
|
// installed before the plugin is loaded (e.g. "tabler", "phosphor"). The
|
|
// standalone-plugin loader honours this best-effort by auto-installing any
|
|
// missing packs from the bundled registry; slugs outside that whitelist are
|
|
// logged and skipped (admins install them manually). Empty / omitted means
|
|
// the plugin has no icon-pack dependencies.
|
|
RequiredIconPacks []string `toml:"required_icon_packs,omitempty"`
|
|
// Private marks the plugin as account-scoped. When true, Coords() returns
|
|
// the canonical "@private/<name>@<version>" form regardless of the Scope
|
|
// field, and the publish flow attributes the plugin to the publisher's
|
|
// active account rather than to a public scope.
|
|
Private bool `toml:"private,omitempty"`
|
|
// DataDir requests a persistent per-plugin /data preopen at load time. It
|
|
// is a first-class field (not an arbitrary key) precisely so the CLI's
|
|
// mod round-trip cannot silently drop it: writeMod reconstructs plugin.mod
|
|
// from struct fields only, so any key without a home here is lost on the
|
|
// next `ninja plugin init`/bump. `ninja plugin build` stamps this into the
|
|
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
|
|
// straight from plugin.mod as a fallback. OFF by default.
|
|
DataDir bool `toml:"data_dir,omitempty"`
|
|
}
|
|
|
|
type ModCompat struct {
|
|
BlockCore string `toml:"block_core"`
|
|
}
|
|
|
|
type ModRequirement struct {
|
|
Name string `toml:"name"`
|
|
Version string `toml:"version"`
|
|
}
|
|
|
|
func ParseModFull(b []byte) (*ModFile, error) {
|
|
var m ModFile
|
|
if err := tomlpkg.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// Coords returns the canonical display coordinate for the plugin in the form
|
|
// "@scope/name@version" (or "name@version" when no scope is set).
|
|
//
|
|
// The leading "@" on m.Plugin.Scope is intentionally trimmed before
|
|
// re-prefixing so that authors may write either "@themes" or "themes" in
|
|
// plugin.mod and get the same output. Callers that need the raw scope as
|
|
// written should read m.Plugin.Scope directly.
|
|
func (m *ModFile) Coords() string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
if m.Plugin.Private {
|
|
return "@" + PrivateScopeSlug + "/" + m.Plugin.Name + "@" + m.Plugin.Version
|
|
}
|
|
scope := strings.TrimPrefix(m.Plugin.Scope, "@")
|
|
if scope == "" {
|
|
return m.Plugin.Name + "@" + m.Plugin.Version
|
|
}
|
|
return "@" + scope + "/" + m.Plugin.Name + "@" + m.Plugin.Version
|
|
}
|
|
|
|
// PrivateScopeSlug is the registry namespace under which all private plugins
|
|
// live. Coords for private plugins resolve to "@private/<name>@<version>";
|
|
// uniqueness is enforced by (owner_account_id, name), not by the slug.
|
|
const PrivateScopeSlug = "private"
|
|
|
|
const (
|
|
TagMinLen = 2
|
|
TagMaxLen = 30
|
|
TagMaxCount = 10
|
|
)
|
|
|
|
// tagSlugRe matches lowercase a-z, 0-9, with single hyphens between groups.
|
|
// Rejects leading/trailing/consecutive hyphens.
|
|
var tagSlugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
|
|
|
// NormalizeTags trims, lowercases, dedupes (case-insensitively), validates,
|
|
// and caps a slice of tags. Returns the cleaned slice or an error listing
|
|
// every offending input so authors fix them in one pass.
|
|
//
|
|
// Rules:
|
|
// - trim surrounding whitespace; drop empty entries silently
|
|
// - lowercase
|
|
// - require [a-z0-9-]{TagMinLen..TagMaxLen}, no leading/trailing/consecutive hyphens
|
|
// - dedupe case-insensitively, preserving first occurrence order
|
|
// - at most TagMaxCount entries (counted after dedupe)
|
|
func NormalizeTags(in []string) ([]string, error) {
|
|
seen := make(map[string]struct{}, len(in))
|
|
out := make([]string, 0, len(in))
|
|
var bad []string
|
|
for _, raw := range in {
|
|
t := strings.ToLower(strings.TrimSpace(raw))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[t]; dup {
|
|
continue
|
|
}
|
|
if len(t) < TagMinLen || len(t) > TagMaxLen || !tagSlugRe.MatchString(t) {
|
|
bad = append(bad, raw)
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
}
|
|
if len(bad) > 0 {
|
|
return nil, fmt.Errorf(
|
|
"invalid tags (must be %d-%d chars, lowercase a-z 0-9 and single hyphens, no leading/trailing hyphen): %s",
|
|
TagMinLen, TagMaxLen, strings.Join(bad, ", "),
|
|
)
|
|
}
|
|
if len(out) > TagMaxCount {
|
|
return nil, fmt.Errorf("too many tags: got %d, max %d", len(out), TagMaxCount)
|
|
}
|
|
return out, nil
|
|
}
|