feat(bnp): codeless .bnp artifacts — declarative plugins with no wasm (WO-WZ-020)
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>
This commit is contained in:
parent
925b6051a3
commit
a3b261dbe4
@ -113,6 +113,18 @@ message PluginManifest {
|
||||
// name the host registers a pongo2 filter that invokes the guest via
|
||||
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
|
||||
repeated string declared_filters = 32;
|
||||
|
||||
// codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020):
|
||||
// the host runs it entirely — block definitions from the artifact's
|
||||
// blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/,
|
||||
// assets/migrations as usual — and never instantiates a guest. A codeless
|
||||
// manifest MUST NOT declare any computing hook (http handler, jobs,
|
||||
// load/unload, RAG fetchers, media hooks, tags/filters, provisioner,
|
||||
// service bindings, directory callbacks); the packer and the host reader
|
||||
// both reject the combination. Not produced by DESCRIBE (there is no guest
|
||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||
// plugin.mod + the artifact's declarative files.
|
||||
bool codeless = 33;
|
||||
}
|
||||
|
||||
// Dependency mirrors plugin.Dependency.
|
||||
|
||||
@ -112,8 +112,19 @@ type PluginManifest struct {
|
||||
// name the host registers a pongo2 filter that invokes the guest via
|
||||
// HOOK_APPLY_FILTER. Captured by DESCRIBE from the blocks filter registry.
|
||||
DeclaredFilters []string `protobuf:"bytes,32,rep,name=declared_filters,json=declaredFilters,proto3" json:"declared_filters,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// codeless marks a declarative artifact with NO plugin.wasm (WO-WZ-020):
|
||||
// the host runs it entirely — block definitions from the artifact's
|
||||
// blocks/ dir (blocks.yaml manifest-FS layout), seed data from seed/,
|
||||
// assets/migrations as usual — and never instantiates a guest. A codeless
|
||||
// manifest MUST NOT declare any computing hook (http handler, jobs,
|
||||
// load/unload, RAG fetchers, media hooks, tags/filters, provisioner,
|
||||
// service bindings, directory callbacks); the packer and the host reader
|
||||
// both reject the combination. Not produced by DESCRIBE (there is no guest
|
||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||
// plugin.mod + the artifact's declarative files.
|
||||
Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PluginManifest) Reset() {
|
||||
@ -370,6 +381,13 @@ func (x *PluginManifest) GetDeclaredFilters() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PluginManifest) GetCodeless() bool {
|
||||
if x != nil {
|
||||
return x.Codeless
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Dependency mirrors plugin.Dependency.
|
||||
type Dependency struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -1282,7 +1300,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_v1_manifest_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\xf2\f\n" +
|
||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" +
|
||||
"\x0ePluginManifest\x12\x1f\n" +
|
||||
"\vabi_version\x18\x01 \x01(\rR\n" +
|
||||
"abiVersion\x12\x12\n" +
|
||||
@ -1319,7 +1337,8 @@ const file_v1_manifest_proto_rawDesc = "" +
|
||||
"\x15core_service_bindings\x18\x1d \x03(\v2\x1a.abi.v1.CoreServiceBindingR\x13coreServiceBindings\x12\x19\n" +
|
||||
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
|
||||
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
|
||||
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x1aB\n" +
|
||||
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
|
||||
"\bcodeless\x18! \x01(\bR\bcodeless\x1aB\n" +
|
||||
"\x14RbacMethodRolesEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
func newPluginBuildCmd() *cobra.Command {
|
||||
var dir, output string
|
||||
var codeless bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "build",
|
||||
Short: "Compile a plugin to wasm and pack a .bnp artifact",
|
||||
@ -23,7 +24,22 @@ schemas/, assets/, web/dist when present).
|
||||
No Docker or podman: the whole pipeline is the local Go toolchain (>= 1.24)
|
||||
plus wazero. This replaces the in-container .so compile.`,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
res, err := bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
|
||||
// Classify by repo shape (WO-WZ-020): no Go source → codeless
|
||||
// (declarative, no wasm); Go source → wasm. --codeless asserts
|
||||
// the expectation and fails loudly on a mismatch.
|
||||
isCodeless, err := bnp.IsCodelessRepo(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if codeless && !isCodeless {
|
||||
return fmt.Errorf("--codeless asserted but %s contains Go source — a codeless plugin has no code (delete the Go or drop the flag)", dir)
|
||||
}
|
||||
var res *bnp.BuildResult
|
||||
if isCodeless {
|
||||
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
|
||||
} else {
|
||||
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir, Output: output})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -33,6 +49,7 @@ plus wazero. This replaces the in-container .so compile.`,
|
||||
}
|
||||
cmd.Flags().StringVar(&dir, "dir", ".", "Plugin repo directory")
|
||||
cmd.Flags().StringVarP(&output, "output", "o", "", "Output .bnp path (default <name>-<version>.bnp)")
|
||||
cmd.Flags().BoolVar(&codeless, "codeless", false, "Assert the repo builds a codeless (no-wasm) artifact; fail if it contains Go source")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -66,7 +83,11 @@ func printBuildSummary(r *bnp.BuildResult) {
|
||||
if len(r.IncludedDirs) > 0 {
|
||||
dirs = strings.Join(r.IncludedDirs, ", ")
|
||||
}
|
||||
fmt.Printf("Built %s@%s → %s\n", r.Name, r.Version, r.OutputPath)
|
||||
kind := "wasm"
|
||||
if r.Codeless {
|
||||
kind = "codeless"
|
||||
}
|
||||
fmt.Printf("Built %s@%s (%s) → %s\n", r.Name, r.Version, kind, r.OutputPath)
|
||||
fmt.Println(" ┌───────────────────────────────────────────")
|
||||
fmt.Printf(" │ artifact size %s (%s uncompressed)\n", humanBytes(r.ArtifactBytes), humanBytes(r.UncompBytes))
|
||||
fmt.Printf(" │ plugin.wasm %s\n", humanBytes(r.WasmBytes))
|
||||
|
||||
@ -44,6 +44,8 @@ type BuildResult struct {
|
||||
OutputPath string
|
||||
Name string
|
||||
Version string
|
||||
// Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020).
|
||||
Codeless bool
|
||||
WasmBytes int64
|
||||
ManifestBytes int64
|
||||
ArtifactBytes int64 // on-disk .bnp size
|
||||
@ -136,11 +138,22 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
|
||||
// dir-name → source subpath. web ships the Module Federation build output
|
||||
// (web/dist) flattened under web/ so the reader's dirExists("web") fires.
|
||||
optional := []struct{ artifact, src string }{
|
||||
{dirBlocks, dirBlocks},
|
||||
{dirTemplates, dirTemplates},
|
||||
{dirSeed, dirSeed},
|
||||
{"migrations", "migrations"},
|
||||
{"schemas", "schemas"},
|
||||
{"assets", "assets"},
|
||||
{"web", filepath.Join("web", "dist")},
|
||||
}
|
||||
// Declarative dirs ride along on wasm artifacts too (a reduced plugin may
|
||||
// mix definition-backed blocks with logic); validate them identically.
|
||||
if _, err := validateBlocksDir(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateSeedDir(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range optional {
|
||||
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
|
||||
if dErr != nil {
|
||||
|
||||
429
cmd/ninja/internal/bnp/codeless.go
Normal file
429
cmd/ninja/internal/bnp/codeless.go
Normal file
@ -0,0 +1,429 @@
|
||||
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
|
||||
}
|
||||
152
cmd/ninja/internal/bnp/codeless_test.go
Normal file
152
cmd/ninja/internal/bnp/codeless_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package bnp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
|
||||
)
|
||||
|
||||
// writeFixture lays out a minimal codeless plugin repo.
|
||||
func writeFixture(t *testing.T, dir string, files map[string]string) {
|
||||
t.Helper()
|
||||
for rel, content := range files {
|
||||
path := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func codelessFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"fixture-theme\"\nversion = \"0.1.0\"\nkind = \"theme\"\n",
|
||||
"manifest.yaml": "theme_presets: presets.json\nrequired_icon_packs: [lucide]\n" +
|
||||
"master_pages: master_pages.json\n",
|
||||
"presets.json": `{"presets":[{"key":"default"}]}`,
|
||||
"master_pages.json": `[{"key":"landing","title":"Landing","blocks":[{"block_key":"html","title":"Hero","content":{"x":1},"slot":"main","sort_order":1}]}]`,
|
||||
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n category: content\n" +
|
||||
" schema: hero.schema.json\n template: hero.ninjatpl\n providers: [site]\n",
|
||||
"blocks/hero.schema.json": `{"type":"object"}`,
|
||||
"blocks/hero.ninjatpl": `<h1>{{ title }}</h1>`,
|
||||
"seed/seed.json": `{"settings":{"ensure":{"welcome":true}},` +
|
||||
`"pages":[{"slug":"/","title":"Home","template_key":"landing"}],` +
|
||||
`"menu_items":[{"menu":"main","label":"Home","page_slug":"/","sort_order":1}]}`,
|
||||
"assets/logo.svg": `<svg/>`,
|
||||
})
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestCodelessBuildAndVerifyRoundTrip(t *testing.T) {
|
||||
dir := codelessFixture(t)
|
||||
|
||||
isCodeless, err := IsCodelessRepo(dir)
|
||||
if err != nil || !isCodeless {
|
||||
t.Fatalf("IsCodelessRepo = %v, %v; want true", isCodeless, err)
|
||||
}
|
||||
|
||||
out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp")
|
||||
res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCodeless: %v", err)
|
||||
}
|
||||
if !res.Codeless || res.BlockCount != 1 {
|
||||
t.Errorf("result = %+v; want codeless with 1 block", res)
|
||||
}
|
||||
|
||||
v, err := Verify(out)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if !v.Codeless || !v.HasBlocks || !v.HasSeed || !v.HasAssets || v.Name != "fixture-theme" {
|
||||
t.Errorf("verify = %+v", v)
|
||||
}
|
||||
|
||||
m, err := ReadManifest(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.GetCodeless() || len(m.GetMasterPages()) != 1 || len(m.GetThemePresets()) == 0 {
|
||||
t.Errorf("manifest = codeless:%v masters:%d presets:%dB",
|
||||
m.GetCodeless(), len(m.GetMasterPages()), len(m.GetThemePresets()))
|
||||
}
|
||||
if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "lucide" {
|
||||
t.Errorf("icon packs = %v", m.GetRequiredIconPacks())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCodelessRepoFalseWithGoSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||
"main.go": "package main\nfunc main() {}\n",
|
||||
})
|
||||
isCodeless, err := IsCodelessRepo(dir)
|
||||
if err != nil || isCodeless {
|
||||
t.Fatalf("IsCodelessRepo = %v, %v; want false", isCodeless, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodelessBuildRejectsBadDeclarations(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
files map[string]string
|
||||
}{
|
||||
{"bad blocks yaml", map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||
"blocks/blocks.yaml": "blocks:\n - key: hero\n", // missing title/schema/template
|
||||
}},
|
||||
{"missing template file", map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||
"blocks/blocks.yaml": "blocks:\n - key: hero\n title: Hero\n" +
|
||||
" schema: hero.schema.json\n template: missing.ninjatpl\n",
|
||||
"blocks/hero.schema.json": `{}`,
|
||||
}},
|
||||
{"seed media without id", map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
||||
"seed/seed.json": `{"media":[{"file":"a.jpg"}]}`,
|
||||
"seed/a.jpg": "x",
|
||||
}},
|
||||
{"data_dir grant", map[string]string{
|
||||
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\ndata_dir = true\n",
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFixture(t, dir, tc.files)
|
||||
_, err := BuildCodeless(context.Background(), BuildOptions{
|
||||
Dir: dir, Output: filepath.Join(t.TempDir(), "x.bnp"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("BuildCodeless succeeded; want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodelessHookViolation(t *testing.T) {
|
||||
ok := &abiv1.PluginManifest{Codeless: true, Name: "x"}
|
||||
if err := CodelessHookViolation(ok); err != nil {
|
||||
t.Fatalf("clean manifest flagged: %v", err)
|
||||
}
|
||||
bad := []*abiv1.PluginManifest{
|
||||
{Codeless: true, HasHttpHandler: true},
|
||||
{Codeless: true, JobTypes: []string{"j"}},
|
||||
{Codeless: true, DeclaredTags: []string{"t"}},
|
||||
{Codeless: true, Blocks: []*abiv1.BlockMeta{{Key: "k"}}},
|
||||
{Codeless: true, DataDir: true},
|
||||
}
|
||||
for i, m := range bad {
|
||||
if err := CodelessHookViolation(m); err == nil {
|
||||
t.Errorf("case %d not flagged", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -37,12 +37,16 @@ type VerifyResult struct {
|
||||
Name string
|
||||
Version string
|
||||
ABIVersion uint32
|
||||
Codeless bool
|
||||
DataDir bool
|
||||
BlockCount int
|
||||
HasMigrations bool
|
||||
HasSchemas bool
|
||||
HasAssets bool
|
||||
HasWeb bool
|
||||
HasBlocks bool
|
||||
HasTemplates bool
|
||||
HasSeed bool
|
||||
}
|
||||
|
||||
// Verify extracts bnpPath into a temp dir and applies the reader's checks:
|
||||
@ -67,7 +71,7 @@ func Verify(bnpPath string) (*VerifyResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, req := range []string{fileWasm, fileMod, fileManifest} {
|
||||
for _, req := range []string{fileMod, fileManifest} {
|
||||
if !fileRegular(filepath.Join(destDir, req)) {
|
||||
return nil, fmt.Errorf("bnp: artifact missing required %s", req)
|
||||
}
|
||||
@ -81,6 +85,19 @@ func Verify(bnpPath string) (*VerifyResult, error) {
|
||||
if err := proto.Unmarshal(manifestBytes, manifest); err != nil {
|
||||
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
|
||||
}
|
||||
// plugin.wasm is required exactly when the manifest is NOT codeless; a
|
||||
// codeless artifact must additionally declare no computing hooks
|
||||
// (WO-WZ-020 — the classifier should have caught this at build).
|
||||
if manifest.GetCodeless() {
|
||||
if fileRegular(filepath.Join(destDir, fileWasm)) {
|
||||
return nil, errors.New("bnp: codeless manifest but plugin.wasm present — rebuild without the wasm or drop codeless")
|
||||
}
|
||||
if err := CodelessHookViolation(manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !fileRegular(filepath.Join(destDir, fileWasm)) {
|
||||
return nil, fmt.Errorf("bnp: artifact missing required %s", fileWasm)
|
||||
}
|
||||
if v := manifest.GetAbiVersion(); v != supportedABIMajor {
|
||||
return nil, fmt.Errorf("bnp: unsupported abi_version %d (host supports %d)", v, supportedABIMajor)
|
||||
}
|
||||
@ -105,15 +122,53 @@ func Verify(bnpPath string) (*VerifyResult, error) {
|
||||
Name: name,
|
||||
Version: manifest.GetVersion(),
|
||||
ABIVersion: manifest.GetAbiVersion(),
|
||||
Codeless: manifest.GetCodeless(),
|
||||
DataDir: manifest.GetDataDir(),
|
||||
BlockCount: len(manifest.GetBlocks()),
|
||||
HasMigrations: dirExists(filepath.Join(destDir, "migrations")),
|
||||
HasSchemas: dirExists(filepath.Join(destDir, "schemas")),
|
||||
HasAssets: dirExists(filepath.Join(destDir, "assets")),
|
||||
HasWeb: dirExists(filepath.Join(destDir, "web")),
|
||||
HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)),
|
||||
HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)),
|
||||
HasSeed: dirExists(filepath.Join(destDir, dirSeed)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CodelessHookViolation returns a named error when a codeless manifest
|
||||
// declares any capability that requires guest code. Mirrored by the CMS
|
||||
// reader (lockstep duplication, same as the rest of this file).
|
||||
func CodelessHookViolation(m *abiv1.PluginManifest) error {
|
||||
viol := func(what string) error {
|
||||
return fmt.Errorf("bnp: codeless manifest declares %s — that requires guest code; build a wasm plugin instead", what)
|
||||
}
|
||||
switch {
|
||||
case m.GetHasHttpHandler():
|
||||
return viol("an HTTP handler")
|
||||
case m.GetHasLoadHook() || m.GetHasUnloadHook():
|
||||
return viol("load/unload hooks")
|
||||
case m.GetHasMediaHooks():
|
||||
return viol("media hooks")
|
||||
case m.GetHasProvisioner():
|
||||
return viol("a provisioner hook")
|
||||
case len(m.GetJobTypes()) > 0:
|
||||
return viol("job handlers")
|
||||
case len(m.GetRagContentFetcherTypes()) > 0:
|
||||
return viol("RAG content fetchers")
|
||||
case len(m.GetDeclaredTags()) > 0 || len(m.GetDeclaredFilters()) > 0:
|
||||
return viol("template tags/filters (express pure snippets as template partials)")
|
||||
case len(m.GetRbacMethodRoles()) > 0 || len(m.GetCoreServiceBindings()) > 0:
|
||||
return viol("Connect services")
|
||||
case len(m.GetBlocks()) > 0 || len(m.GetTemplateKeys()) > 0 || len(m.GetSystemTemplates()) > 0 || len(m.GetPageTemplates()) > 0:
|
||||
return viol("guest-rendered blocks/templates (codeless blocks live in blocks/blocks.yaml)")
|
||||
case m.GetDirectoryExtensions().GetPanelSectionCount() > 0 || m.GetDirectoryExtensions().GetPinDecoratorCount() > 0:
|
||||
return viol("directory extension callbacks")
|
||||
case m.GetDataDir():
|
||||
return viol("a data_dir grant")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadManifest extracts a .bnp into a temp dir (with the same path-safety and
|
||||
// size caps as Verify) and returns its decoded manifest.pb. It does not enforce
|
||||
// the name-match / abi checks — use Verify for the full gate.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user