PluginManifest gains `codeless` (additive, buf-breaking clean): an artifact with NO plugin.wasm that the host runs entirely. `ninja plugin build` classifies by repo shape — no Go source → codeless (manifest synthesized from plugin.mod + optional manifest.yaml: theme presets, fonts, settings schema, master pages, CSS, icon packs, deps); Go source → wasm as always. New `--codeless` flag asserts the expectation. Both artifact kinds now pack the declarative dirs: blocks/ (the cms blocks.yaml manifest-FS layout — definition-backed blocks), templates/, and seed/ (seed.json: settings/media/pages/menu items, applied host-side via the WO-WZ-019 provisioner). The packer structurally validates blocks.yaml and seed.json (schema JSON validity, file presence, traversal safety); full semantic validation stays host-side at install. `ninja plugin verify`: plugin.wasm is required exactly when NOT codeless; a codeless manifest declaring any computing hook (http/jobs/load/unload/media/ RAG/tags/filters/services/guest blocks/data_dir) is rejected — CodelessHookViolation mirrors the cms reader check (lockstep duplication). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
430 lines
13 KiB
Go
430 lines
13 KiB
Go
package bnp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
|
core "git.dev.alexdunmow.com/block/core/plugin"
|
|
"google.golang.org/protobuf/proto"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Codeless artifacts (WO-WZ-020): a .bnp with NO plugin.wasm — pure
|
|
// declaration the host runs. Classification is by repo shape: a plugin repo
|
|
// with no Go source builds codeless; a repo with Go builds wasm as always
|
|
// (a converted repo DELETES its Go — that's the point). Both artifact kinds
|
|
// may carry blocks/, templates/, and seed/; codeless just has nothing else.
|
|
|
|
// Declarative artifact dirs shared by both build paths.
|
|
const (
|
|
dirBlocks = "blocks"
|
|
dirTemplates = "templates"
|
|
dirSeed = "seed"
|
|
)
|
|
|
|
// manifestYAMLName is the optional root-level declarative manifest source for
|
|
// codeless plugins (the counterpart of what DESCRIBE captures from Go code).
|
|
const manifestYAMLName = "manifest.yaml"
|
|
|
|
// manifestYAML mirrors the declarative PluginManifest fields a codeless
|
|
// plugin can set. File-valued keys are paths relative to the repo root.
|
|
type manifestYAML struct {
|
|
ThemePresets string `yaml:"theme_presets"` // JSON file
|
|
BundledFonts string `yaml:"bundled_fonts"` // JSON file
|
|
SettingsSchema string `yaml:"settings_schema"` // JSON file
|
|
MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON)
|
|
RequiredIconPacks []string `yaml:"required_icon_packs"`
|
|
CSS *struct {
|
|
NpmPackages map[string]string `yaml:"npm_packages"`
|
|
CSSDirectives []string `yaml:"css_directives"`
|
|
InputCSSAppend string `yaml:"input_css_append"`
|
|
} `yaml:"css"`
|
|
Dependencies []struct {
|
|
Plugin string `yaml:"plugin"`
|
|
MinVersion string `yaml:"min_version"`
|
|
Required bool `yaml:"required"`
|
|
} `yaml:"dependencies"`
|
|
}
|
|
|
|
// masterPageJSON mirrors abiv1.MasterPageDefinition for the declarative file.
|
|
type masterPageJSON struct {
|
|
Key string `json:"key"`
|
|
Title string `json:"title"`
|
|
PageTemplates []string `json:"page_templates"`
|
|
Blocks []struct {
|
|
BlockKey string `json:"block_key"`
|
|
Title string `json:"title"`
|
|
Content map[string]any `json:"content"`
|
|
HTMLContent *string `json:"html_content"`
|
|
Slot string `json:"slot"`
|
|
SortOrder int32 `json:"sort_order"`
|
|
} `json:"blocks"`
|
|
}
|
|
|
|
// blocksYAML is the structural mirror of the CMS blocks.yaml manifest
|
|
// (cms blocks.LoadManifest) — a deliberate light duplication, same as the
|
|
// Verify↔reader pairing: the packer validates shape and file presence; full
|
|
// semantic validation (schema/template/provider checks) runs at install.
|
|
type blocksYAML struct {
|
|
Blocks []struct {
|
|
Key string `yaml:"key"`
|
|
Title string `yaml:"title"`
|
|
Description string `yaml:"description"`
|
|
Category string `yaml:"category"`
|
|
Schema string `yaml:"schema"`
|
|
Template string `yaml:"template"`
|
|
SampleData string `yaml:"sample_data"`
|
|
Providers []string `yaml:"providers"`
|
|
RequiredTags []string `yaml:"required_tags"`
|
|
Aliases []string `yaml:"aliases"`
|
|
} `yaml:"blocks"`
|
|
}
|
|
|
|
// seedJSON is the declarative seed schema (seed/seed.json). Applied by the
|
|
// host at load via the WO-WZ-019 provisioner (idempotent).
|
|
type seedJSON struct {
|
|
Settings *struct {
|
|
Merge map[string]any `json:"merge"`
|
|
Override map[string]any `json:"override"`
|
|
Ensure map[string]any `json:"ensure"`
|
|
} `json:"settings"`
|
|
Media []struct {
|
|
ID string `json:"id"` // UUID, required (deterministic media key)
|
|
File string `json:"file"`
|
|
Alt string `json:"alt"`
|
|
Folder string `json:"folder"`
|
|
} `json:"media"`
|
|
Pages []struct {
|
|
Slug string `json:"slug"`
|
|
ParentSlug string `json:"parent_slug"`
|
|
Title string `json:"title"`
|
|
TemplateKey string `json:"template_key"`
|
|
ReconcileBlocks bool `json:"reconcile_blocks"`
|
|
ReconcileTemplate bool `json:"reconcile_template"`
|
|
Blocks []struct {
|
|
BlockKey string `json:"block_key"`
|
|
Title string `json:"title"`
|
|
Content map[string]any `json:"content"`
|
|
HTMLContent *string `json:"html_content"`
|
|
Slot string `json:"slot"`
|
|
SortOrder int32 `json:"sort_order"`
|
|
} `json:"blocks"`
|
|
} `json:"pages"`
|
|
MenuItems []struct {
|
|
Menu string `json:"menu"`
|
|
Label string `json:"label"`
|
|
URL string `json:"url"`
|
|
PageSlug string `json:"page_slug"`
|
|
SortOrder int32 `json:"sort_order"`
|
|
} `json:"menu_items"`
|
|
}
|
|
|
|
// IsCodelessRepo reports whether dir is a declarative plugin repo: it has a
|
|
// plugin.mod but no Go source at the root (the wasm main package's home).
|
|
func IsCodelessRepo(dir string) (bool, error) {
|
|
if _, err := os.Stat(filepath.Join(dir, fileMod)); err != nil {
|
|
return false, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err)
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// BuildCodeless synthesizes manifest.pb from plugin.mod + the declarative
|
|
// files and packs a codeless .bnp (no plugin.wasm, no DESCRIBE probe).
|
|
func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
|
|
dir := opts.Dir
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
dir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve dir: %w", err)
|
|
}
|
|
|
|
modPath := filepath.Join(dir, fileMod)
|
|
modBytes, err := os.ReadFile(modPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read plugin.mod: %w", err)
|
|
}
|
|
mod, err := core.ParseModFull(modBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse plugin.mod: %w", err)
|
|
}
|
|
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
|
|
return nil, fmt.Errorf("plugin.mod must set both name and version")
|
|
}
|
|
if mod.Plugin.DataDir {
|
|
return nil, fmt.Errorf("codeless plugin cannot request data_dir — there is no code to use it")
|
|
}
|
|
|
|
manifest := &abiv1.PluginManifest{
|
|
AbiVersion: hostAbiVersion,
|
|
Name: mod.Plugin.Name,
|
|
Version: mod.Plugin.Version,
|
|
Codeless: true,
|
|
}
|
|
|
|
blockCount, err := validateBlocksDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateSeedDir(dir); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := applyManifestYAML(dir, manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
manifestBytes, err := proto.Marshal(manifest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
|
|
}
|
|
|
|
entries := []packEntry{
|
|
{ArtifactPath: fileMod, Source: modPath},
|
|
{ArtifactPath: fileManifest, Data: manifestBytes},
|
|
}
|
|
var includedDirs []string
|
|
optional := []struct{ artifact, src string }{
|
|
{dirBlocks, dirBlocks},
|
|
{dirTemplates, dirTemplates},
|
|
{dirSeed, dirSeed},
|
|
{"migrations", "migrations"},
|
|
{"schemas", "schemas"},
|
|
{"assets", "assets"},
|
|
}
|
|
for _, o := range optional {
|
|
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
|
|
if dErr != nil {
|
|
return nil, dErr
|
|
}
|
|
if has {
|
|
entries = append(entries, dirEntries...)
|
|
includedDirs = append(includedDirs, o.artifact)
|
|
}
|
|
}
|
|
|
|
outPath := opts.Output
|
|
if outPath == "" {
|
|
outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version)
|
|
}
|
|
uncomp, err := packArtifact(outPath, entries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
artInfo, err := os.Stat(outPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat artifact: %w", err)
|
|
}
|
|
|
|
return &BuildResult{
|
|
OutputPath: outPath,
|
|
Name: manifest.GetName(),
|
|
Version: manifest.GetVersion(),
|
|
Codeless: true,
|
|
ManifestBytes: int64(len(manifestBytes)),
|
|
ArtifactBytes: artInfo.Size(),
|
|
UncompBytes: uncomp,
|
|
BlockCount: blockCount,
|
|
IncludedDirs: includedDirs,
|
|
}, nil
|
|
}
|
|
|
|
// validateBlocksDir structurally validates blocks/blocks.yaml when present:
|
|
// every entry has key/title/schema/template, referenced files exist within
|
|
// blocks/, and schemas are valid JSON. Returns the block count (0 when the
|
|
// dir is absent).
|
|
func validateBlocksDir(dir string) (int, error) {
|
|
root := filepath.Join(dir, dirBlocks)
|
|
manifestPath := filepath.Join(root, "blocks.yaml")
|
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
|
return 0, nil
|
|
}
|
|
raw, err := os.ReadFile(manifestPath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("blocks/ present but blocks.yaml unreadable: %w", err)
|
|
}
|
|
var bf blocksYAML
|
|
if err := yaml.Unmarshal(raw, &bf); err != nil {
|
|
return 0, fmt.Errorf("parse blocks/blocks.yaml: %w", err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for i, b := range bf.Blocks {
|
|
if b.Key == "" {
|
|
return 0, fmt.Errorf("blocks.yaml: entry %d has no key", i)
|
|
}
|
|
if seen[b.Key] {
|
|
return 0, fmt.Errorf("blocks.yaml: duplicate key %q", b.Key)
|
|
}
|
|
seen[b.Key] = true
|
|
if b.Title == "" || b.Schema == "" || b.Template == "" {
|
|
return 0, fmt.Errorf("blocks.yaml: %q must declare title, schema, and template", b.Key)
|
|
}
|
|
for _, f := range []string{b.Schema, b.Template, b.SampleData} {
|
|
if f == "" {
|
|
continue
|
|
}
|
|
if err := fileWithin(root, f); err != nil {
|
|
return 0, fmt.Errorf("blocks.yaml: %q: %w", b.Key, err)
|
|
}
|
|
}
|
|
schemaRaw, err := os.ReadFile(filepath.Join(root, b.Schema))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("blocks.yaml: %q schema: %w", b.Key, err)
|
|
}
|
|
if !json.Valid(schemaRaw) {
|
|
return 0, fmt.Errorf("blocks.yaml: %q schema %s is not valid JSON", b.Key, b.Schema)
|
|
}
|
|
}
|
|
return len(bf.Blocks), nil
|
|
}
|
|
|
|
// validateSeedDir validates seed/seed.json when the dir is present: it
|
|
// parses, media entries carry an id + an existing file, pages carry slugs.
|
|
func validateSeedDir(dir string) error {
|
|
root := filepath.Join(dir, dirSeed)
|
|
if _, err := os.Stat(root); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(root, "seed.json"))
|
|
if err != nil {
|
|
return fmt.Errorf("seed/ present but seed.json unreadable: %w", err)
|
|
}
|
|
var sf seedJSON
|
|
if err := json.Unmarshal(raw, &sf); err != nil {
|
|
return fmt.Errorf("parse seed/seed.json: %w", err)
|
|
}
|
|
for i, m := range sf.Media {
|
|
if m.ID == "" || m.File == "" {
|
|
return fmt.Errorf("seed.json: media entry %d needs both id and file", i)
|
|
}
|
|
if err := fileWithin(root, m.File); err != nil {
|
|
return fmt.Errorf("seed.json: media %q: %w", m.ID, err)
|
|
}
|
|
}
|
|
for i, p := range sf.Pages {
|
|
if p.Slug == "" || p.Title == "" {
|
|
return fmt.Errorf("seed.json: page entry %d needs slug and title", i)
|
|
}
|
|
}
|
|
for i, mi := range sf.MenuItems {
|
|
if mi.Menu == "" || mi.Label == "" {
|
|
return fmt.Errorf("seed.json: menu item %d needs menu and label", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyManifestYAML folds the optional root manifest.yaml into the manifest.
|
|
func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
|
|
raw, err := os.ReadFile(filepath.Join(dir, manifestYAMLName))
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", manifestYAMLName, err)
|
|
}
|
|
var my manifestYAML
|
|
if err := yaml.Unmarshal(raw, &my); err != nil {
|
|
return fmt.Errorf("parse %s: %w", manifestYAMLName, err)
|
|
}
|
|
|
|
readJSONFile := func(rel, what string) ([]byte, error) {
|
|
if rel == "" {
|
|
return nil, nil
|
|
}
|
|
if err := fileWithin(dir, rel); err != nil {
|
|
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(dir, rel))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
|
|
}
|
|
if !json.Valid(b) {
|
|
return nil, fmt.Errorf("%s: %s %s is not valid JSON", manifestYAMLName, what, rel)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
|
|
return err
|
|
}
|
|
if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
|
|
return err
|
|
}
|
|
if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
|
|
return err
|
|
}
|
|
m.RequiredIconPacks = my.RequiredIconPacks
|
|
if my.CSS != nil {
|
|
m.CssManifest = &abiv1.CssManifest{
|
|
NpmPackages: my.CSS.NpmPackages,
|
|
CssDirectives: my.CSS.CSSDirectives,
|
|
InputCssAppend: my.CSS.InputCSSAppend,
|
|
}
|
|
}
|
|
for _, d := range my.Dependencies {
|
|
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
|
|
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
|
|
})
|
|
}
|
|
|
|
if my.MasterPages != "" {
|
|
mpRaw, err := readJSONFile(my.MasterPages, "master_pages")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var mps []masterPageJSON
|
|
if err := json.Unmarshal(mpRaw, &mps); err != nil {
|
|
return fmt.Errorf("%s: master_pages %s: %w", manifestYAMLName, my.MasterPages, err)
|
|
}
|
|
for _, mp := range mps {
|
|
def := &abiv1.MasterPageDefinition{
|
|
Key: mp.Key, Title: mp.Title, PageTemplates: mp.PageTemplates,
|
|
}
|
|
for _, b := range mp.Blocks {
|
|
contentJSON, err := json.Marshal(b.Content)
|
|
if err != nil {
|
|
return fmt.Errorf("master page %q block %q: %w", mp.Key, b.BlockKey, err)
|
|
}
|
|
def.Blocks = append(def.Blocks, &abiv1.MasterPageBlock{
|
|
BlockKey: b.BlockKey, Title: b.Title, ContentJson: contentJSON,
|
|
HtmlContent: b.HTMLContent, Slot: b.Slot, SortOrder: b.SortOrder,
|
|
})
|
|
}
|
|
m.MasterPages = append(m.MasterPages, def)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fileWithin ensures rel names an existing regular file inside root (no
|
|
// traversal escapes).
|
|
func fileWithin(root, rel string) error {
|
|
clean := filepath.Clean(rel)
|
|
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
|
|
return fmt.Errorf("path %q escapes the plugin directory", rel)
|
|
}
|
|
fi, err := os.Stat(filepath.Join(root, clean))
|
|
if err != nil {
|
|
return fmt.Errorf("referenced file %q: %w", rel, err)
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return fmt.Errorf("referenced path %q is not a regular file", rel)
|
|
}
|
|
return nil
|
|
}
|