refactor(abi): shed ABI contract + ninja CLI (WO-WZ-023)

The wasm-plugin ABI contract now lives in the cms (block/cms/abi/v1 + docs) and
the ninja CLI moved to its own repo (block/cli). Core keeps abi/ as the guest
SDK until WO-WZ-027. Removes cmd/ninja, the dead orchestrator registry client
(internal/api/orchestrator), the moved docs, and the now-unused ninja/orchclient
deps from go.mod/go.sum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 22:37:24 +08:00
parent 3ce6f9f4a0
commit c7cbf69f63
37 changed files with 13 additions and 11751 deletions

View File

@ -1,9 +1,9 @@
# Core SDK
Go module `git.dev.alexdunmow.com/block/core`. Defines plugin interfaces, template engine, block registry, and shared types for the BlockNinja CMS.
Go module `git.dev.alexdunmow.com/block/core`. Shared Go code between the **CMS and the orchestrator** — template engine, block registry, shared types, proto definitions.
## Critical Rules
- **Core is NOT for plugins.** Its only consumers are the CMS and the orchestrator; it exists purely to share code between those two. Plugins must not import `block/core` — in the wasm-plugin era they are standalone artifacts built against the wasm ABI, not this module. (Core was previously the plugin SDK; that role is gone.)
- **NEVER use `replace` directives in go.mod** — not in this repo, not in any consumer. All module resolution goes through the Gitea module proxy. If you need to test local changes, tag and push a version.
- Plugins import from core only — never from the CMS (`blockninja/backend`) or orchestrator.
- All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers.

View File

@ -8,17 +8,10 @@ SDK_DOWNSTREAM_DIRS := \
$(wildcard $(HOME)/src/blockninja/sites/*) \
$(wildcard $(HOME)/src/blockninja/plugins/*)
.PHONY: install-ninja
install-ninja:
go install ./cmd/ninja
# Regenerate Go bindings from the proto/ submodule. We narrow to
# plugin_registry.proto because other orchestrator/v1 protos (accounts.proto
# etc.) are owned by the orchestrator's generated package; registering them
# from core too would panic at startup with "file ... is already registered".
.PHONY: proto
proto:
buf generate --path proto/orchestrator/v1/plugin_registry.proto
# The ninja CLI moved to its own repo (git.dev.alexdunmow.com/block/cli,
# WO-WZ-023, Phase 7 core dissolution). Its plugin-registry orchestrator client
# is generated there now, so core no longer carries an `install-ninja` or
# `proto` (plugin_registry) target.
# Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf
# module under abi/ — deliberately not part of the proto/ submodule; see

View File

@ -1,6 +1,8 @@
# BlockNinja Plugin SDK
# BlockNinja Core
Types, interfaces, and utilities for building BlockNinja plugins.
Shared Go code between the BlockNinja CMS and the orchestrator: types, interfaces, and utilities both need.
> **Not a plugin SDK.** Core is purely for sharing code between the CMS and the orchestrator. Plugins must **not** import `block/core` — in the wasm-plugin era they are standalone artifacts built against the wasm ABI. (Core previously served as the plugin SDK; that role is gone.)
## Package Structure
@ -23,6 +25,8 @@ Types, interfaces, and utilities for building BlockNinja plugins.
## Usage
Consumers are the CMS and the orchestrator only.
```go
import "git.dev.alexdunmow.com/ninja/core/plugin"
import "git.dev.alexdunmow.com/block/core/plugin"
```

View File

@ -1,150 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newAccountCmd() *cobra.Command {
c := &cobra.Command{
Use: "account",
Short: "Manage which account ninja acts as",
Long: `Account-scoped commands like ` + "`ninja plugins publish --private`" + ` act
against an "active account" the orchestrator-side account whose members
can see and install the plugin. The active account is selected at
` + "`ninja login`" + ` time and persisted in your credentials file.`,
}
c.AddCommand(newAccountListCmd(), newAccountSetCmd(), newAccountShowCmd())
return c
}
func newAccountListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List the accounts the authenticated user belongs to",
RunE: func(c *cobra.Command, _ []string) error {
cli, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(),
connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
if len(accts.Msg.Accounts) == 0 {
fmt.Println("No accounts.")
return nil
}
for _, a := range accts.Msg.Accounts {
marker := " "
if a.Id == hc.ActiveAccountID {
marker = "* "
}
fmt.Printf("%s%s — %s\n", marker, a.Slug, a.Name)
}
return nil
},
}
}
func newAccountSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set <slug>",
Short: "Change the active account",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, cr, host, hc, err := resolveClient(c)
if err != nil {
return err
}
slug := strings.TrimPrefix(args[0], "@")
accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(),
connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
for _, a := range accts.Msg.Accounts {
if a.Slug == slug {
hc.ActiveAccountID = a.Id
hc.ActiveAccountSlug = a.Slug
cr.Hosts[host] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Printf("Active account: %s (%s)\n", a.Slug, a.Name)
return nil
}
}
return fmt.Errorf("account %q not found among your memberships; try `ninja account list`", slug)
},
}
}
func newAccountShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Show the currently active account",
RunE: func(c *cobra.Command, _ []string) error {
_, _, _, hc, err := resolveClient(c)
if err != nil {
return err
}
if hc.ActiveAccountSlug == "" {
fmt.Println("(no active account set; run `ninja login` or `ninja account set <slug>`)")
return nil
}
fmt.Printf("Active account: %s (id=%s)\n", hc.ActiveAccountSlug, hc.ActiveAccountID)
return nil
},
}
}
// resolveClient is a small helper used by every `ninja account` subcommand:
// it loads creds, resolves the host, and returns an authed client plus the
// loaded credentials so the caller can persist changes.
func resolveClient(c *cobra.Command) (*orchclient.Client, *creds.Credentials, string, creds.HostCreds, error) {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return nil, nil, "", creds.HostCreds{}, err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return nil, nil, "", creds.HostCreds{}, err
}
return orchclient.New(resolvedHost, hc.Token), cr, resolvedHost, hc, nil
}
// pickAccountInteractive prompts the user to select an account by number from
// the given list and returns the chosen account. Used by `ninja login` when
// the user belongs to more than one account.
func pickAccountInteractive(scanner *bufio.Scanner, accounts []*v1.MyAccount) (*v1.MyAccount, error) {
if len(accounts) == 0 {
return nil, fmt.Errorf("no accounts available")
}
fmt.Println("Select an account:")
for i, a := range accounts {
fmt.Printf(" %d) %s — %s\n", i+1, a.Slug, a.Name)
}
fmt.Print("> ")
if !scanner.Scan() {
return nil, fmt.Errorf("cancelled")
}
v := strings.TrimSpace(scanner.Text())
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > len(accounts) {
return nil, fmt.Errorf("invalid selection: %s", v)
}
return accounts[n-1], nil
}

View File

@ -1,150 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"time"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newLoginCmd() *cobra.Command {
var host string
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate against the orchestrator using device flow",
RunE: func(c *cobra.Command, _ []string) error {
if host == "" {
host, _ = c.Flags().GetString("host")
}
if host == "" {
host = "https://my.blockninjacms.com"
}
cli := orchclient.New(host, "")
ctx := context.Background()
start, err := cli.Auth.StartDevice(ctx, connect.NewRequest(&v1.StartDeviceRequest{
Scopes: []string{"plugin:read", "plugin:publish", "scope:admin"},
}))
if err != nil {
return fmt.Errorf("start device: %w", err)
}
fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode)
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
for time.Now().Before(deadline) {
time.Sleep(interval)
poll, err := cli.Auth.PollDevice(ctx, connect.NewRequest(&v1.PollDeviceRequest{DeviceCode: start.Msg.DeviceCode}))
if err != nil {
return err
}
switch poll.Msg.Status {
case "pending":
continue
case "approved":
cr, err := creds.Load()
if err != nil {
return err
}
cr.DefaultHost = host
if cr.Hosts == nil {
cr.Hosts = map[string]creds.HostCreds{}
}
hc := creds.HostCreds{Token: poll.Msg.AccessToken}
authed := orchclient.New(host, hc.Token)
if err := selectActiveAccount(ctx, authed, &hc); err != nil {
return err
}
cr.Hosts[host] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Println("Logged in.")
return nil
case "expired":
return fmt.Errorf("device code expired; try again")
}
}
return fmt.Errorf("login timed out")
},
}
cmd.Flags().StringVar(&host, "host", "", "Orchestrator base URL")
return cmd
}
func newWhoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami",
Short: "Show the currently logged-in user",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
r, err := cli.Auth.Whoami(context.Background(), connect.NewRequest(&v1.WhoamiRequest{}))
if err != nil {
return err
}
fmt.Printf("%s <%s> at %s\n", r.Msg.DisplayName, r.Msg.Email, resolvedHost)
return nil
},
}
}
// selectActiveAccount fetches the user's accounts and writes the active one
// into hc. With 0 accounts it errors (the server contract guarantees every
// user has at least one). With 1 it auto-selects silently. With ≥2 it
// prompts interactively on stdin.
func selectActiveAccount(ctx context.Context, cli *orchclient.Client, hc *creds.HostCreds) error {
resp, err := cli.Auth.ListMyAccountsForCLI(ctx, connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
if err != nil {
return fmt.Errorf("list accounts: %w", err)
}
accts := resp.Msg.Accounts
switch len(accts) {
case 0:
return fmt.Errorf("no accounts found for this user; contact support")
case 1:
hc.ActiveAccountID = accts[0].Id
hc.ActiveAccountSlug = accts[0].Slug
fmt.Printf("Active account: %s (%s)\n", accts[0].Slug, accts[0].Name)
return nil
}
chosen, err := pickAccountInteractive(bufio.NewScanner(os.Stdin), accts)
if err != nil {
return err
}
hc.ActiveAccountID = chosen.Id
hc.ActiveAccountSlug = chosen.Slug
fmt.Printf("Active account: %s (%s)\n", chosen.Slug, chosen.Name)
return nil
}
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored credentials",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, _, _ := cr.Resolve(host)
delete(cr.Hosts, resolvedHost)
return cr.Save()
},
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,140 +0,0 @@
package cmd
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/bnp"
)
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",
Long: `Compile the plugin in --dir to reactor-mode wasip1 wasm, extract its
static manifest by instantiating the module once (HOOK_DESCRIBE), and pack a
.bnp (tar.zst of plugin.wasm, plugin.mod, manifest.pb, plus migrations/,
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 {
// 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
}
printBuildSummary(res)
return nil
},
}
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
}
func newPluginVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "verify <file.bnp>",
Short: "Validate a .bnp against the loader's layout/name/abi/path/size checks",
Long: `Re-run the CMS reader's checks on a .bnp standalone so CI and the registry
can gate uploads: required members present, path-safety and size caps on
extraction, manifest decodes, abi_version supported, and manifest name matches
plugin.mod. Prints a named reason for each malformed class.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
res, err := bnp.Verify(args[0])
if err != nil {
return err
}
printVerifySummary(args[0], res)
return nil
},
}
return cmd
}
func printBuildSummary(r *bnp.BuildResult) {
hooks := "(none)"
if len(r.Hooks) > 0 {
hooks = strings.Join(r.Hooks, ", ")
}
dirs := "(none)"
if len(r.IncludedDirs) > 0 {
dirs = strings.Join(r.IncludedDirs, ", ")
}
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))
fmt.Printf(" │ manifest.pb %s\n", humanBytes(r.ManifestBytes))
fmt.Printf(" │ blocks %d\n", r.BlockCount)
fmt.Printf(" │ templates %d\n", r.TemplateCount)
fmt.Printf(" │ admin pages %d\n", r.AdminPages)
fmt.Printf(" │ job types %d\n", r.JobTypes)
fmt.Printf(" │ hooks %s\n", hooks)
fmt.Printf(" │ bundled dirs %s\n", dirs)
fmt.Printf(" │ data_dir grant %t\n", r.DataDir)
fmt.Println(" └───────────────────────────────────────────")
}
func printVerifySummary(path string, r *bnp.VerifyResult) {
var dirs []string
if r.HasMigrations {
dirs = append(dirs, "migrations")
}
if r.HasSchemas {
dirs = append(dirs, "schemas")
}
if r.HasAssets {
dirs = append(dirs, "assets")
}
if r.HasWeb {
dirs = append(dirs, "web")
}
joined := "(none)"
if len(dirs) > 0 {
joined = strings.Join(dirs, ", ")
}
fmt.Printf("OK: %s\n", path)
fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s]\n",
r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined)
}
// humanBytes formats a byte count with a binary unit suffix.
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

View File

@ -1,279 +0,0 @@
package cmd
import (
"archive/tar"
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/bnp"
"github.com/klauspost/compress/zstd"
"google.golang.org/protobuf/proto"
)
// fixtureDir resolves a testdata plugin fixture in the core module tree.
func fixtureDir(t *testing.T, name string) string {
t.Helper()
dir, err := filepath.Abs(filepath.Join("..", "..", "..", "plugin", "wasmguest", "testdata", name))
if err != nil {
t.Fatalf("resolve fixture %s: %v", name, err)
}
if _, err := os.Stat(filepath.Join(dir, "plugin.mod")); err != nil {
t.Fatalf("fixture %s missing plugin.mod: %v", name, err)
}
return dir
}
// TestPluginBuildAndVerify is the WO-WZ-009 acceptance e2e: build the WZ-002
// fixture repo → a .bnp exists, `verify` passes, and its manifest decodes with
// the expected block keys and the data_dir grant from plugin.mod.
func TestPluginBuildAndVerify(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
out := filepath.Join(t.TempDir(), "wasmfixture-0.0.1.bnp")
res, err := bnp.Build(context.Background(), bnp.BuildOptions{
Dir: fixtureDir(t, "fixture"),
Output: out,
})
if err != nil {
t.Fatalf("build: %v", err)
}
if res.Name != "wasmfixture" || res.Version != "0.0.1" {
t.Errorf("identity = %q/%q", res.Name, res.Version)
}
if res.BlockCount != 1 || res.TemplateCount != 1 || res.AdminPages != 1 {
t.Errorf("counts blocks=%d templates=%d admin=%d", res.BlockCount, res.TemplateCount, res.AdminPages)
}
if !res.DataDir {
t.Errorf("data_dir grant not carried from plugin.mod into the manifest")
}
if !slices.Contains(res.Hooks, "load") {
t.Errorf("hooks = %v, want load present", res.Hooks)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("artifact not written: %v", err)
}
// verify passes on the produced artifact.
vr, err := bnp.Verify(out)
if err != nil {
t.Fatalf("verify: %v", err)
}
if vr.Name != "wasmfixture" || vr.ABIVersion != 1 || !vr.DataDir {
t.Errorf("verify result = %+v", vr)
}
// manifest decodes with expected block keys.
m, err := bnp.ReadManifest(out)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var keys []string
for _, b := range m.GetBlocks() {
keys = append(keys, b.GetKey())
}
if !slices.Contains(keys, "wasmfixture:greeting") {
t.Errorf("block keys = %v, want wasmfixture:greeting", keys)
}
if !slices.Contains(m.GetTemplateKeys(), "wasmfixture-page") {
t.Errorf("template keys = %v, want wasmfixture-page", m.GetTemplateKeys())
}
if !m.GetDataDir() {
t.Errorf("manifest.data_dir = false, want true")
}
}
// TestPluginBuildDefaultOutputName confirms the default artifact name is
// <name>-<version>.bnp in the working directory.
func TestPluginBuildDefaultOutputName(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
dir := fixtureDir(t, "fixture") // resolve to an absolute path before chdir
wd, _ := os.Getwd()
t.Cleanup(func() { _ = os.Chdir(wd) })
tmp := t.TempDir()
if err := os.Chdir(tmp); err != nil {
t.Fatalf("chdir: %v", err)
}
res, err := bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir})
if err != nil {
t.Fatalf("build: %v", err)
}
if res.OutputPath != "wasmfixture-0.0.1.bnp" {
t.Errorf("default output = %q", res.OutputPath)
}
if _, err := os.Stat(filepath.Join(tmp, "wasmfixture-0.0.1.bnp")); err != nil {
t.Errorf("default artifact not in cwd: %v", err)
}
}
// TestPluginBuildCapabilityInRegisterFails proves a plugin that reaches a host
// capability at describe time fails with an actionable error naming the call.
func TestPluginBuildCapabilityInRegisterFails(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
out := filepath.Join(t.TempDir(), "capfixture.bnp")
_, err := bnp.Build(context.Background(), bnp.BuildOptions{
Dir: fixtureDir(t, "capfixture"),
Output: out,
})
if err == nil {
t.Fatal("expected build to fail on a describe-time capability call")
}
msg := err.Error()
if !strings.Contains(msg, "db.query") || !strings.Contains(msg, "manifest extraction") {
t.Errorf("error not actionable: %q (want it to name db.query + manifest extraction)", msg)
}
if _, statErr := os.Stat(out); statErr == nil {
t.Errorf("a .bnp must not be written when the build fails")
}
}
// --- verify rejection classes (fast; no wasm compile) ---
type fakeEntry struct {
name string
data []byte
typeflag byte
}
// writeTarZst crafts an arbitrary tar.zst for the malformed-artifact tests.
func writeTarZst(t *testing.T, entries []fakeEntry) string {
t.Helper()
path := filepath.Join(t.TempDir(), "artifact.bnp")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create: %v", err)
}
defer func() { _ = f.Close() }()
enc, _ := zstd.NewWriter(f)
tw := tar.NewWriter(enc)
for _, e := range entries {
typ := e.typeflag
if typ == 0 {
typ = tar.TypeReg
}
hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Size: int64(len(e.data))}
if typ == tar.TypeSymlink {
hdr.Linkname = string(e.data)
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("hdr %q: %v", e.name, err)
}
if typ == tar.TypeReg {
if _, err := tw.Write(e.data); err != nil {
t.Fatalf("write %q: %v", e.name, err)
}
}
}
_ = tw.Close()
_ = enc.Close()
return path
}
func manifestBytes(t *testing.T, name string, abi uint32) []byte {
t.Helper()
b, err := proto.Marshal(&abiv1.PluginManifest{Name: name, Version: "1.0.0", AbiVersion: abi})
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
return b
}
func TestVerifyRejectsMalformed(t *testing.T) {
validMod := []byte("[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n")
cases := []struct {
name string
entries []fakeEntry
wantMsg string
}{
{
name: "missing manifest.pb",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
},
wantMsg: "missing required manifest.pb",
},
{
name: "undecodable manifest",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: []byte("not-a-proto\xff\xff")},
},
wantMsg: "decode manifest.pb",
},
{
name: "unsupported abi_version",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: manifestBytes(t, "demo", 2)},
},
wantMsg: "unsupported abi_version 2",
},
{
name: "empty manifest name",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: manifestBytes(t, "", 1)},
},
wantMsg: "empty name",
},
{
name: "name mismatch",
entries: []fakeEntry{
{name: "plugin.wasm", data: []byte("\x00asm")},
{name: "plugin.mod", data: validMod},
{name: "manifest.pb", data: manifestBytes(t, "other", 1)},
},
wantMsg: "!= plugin.mod name",
},
{
name: "absolute path entry",
entries: []fakeEntry{
{name: "/etc/passwd", data: []byte("x")},
},
wantMsg: "absolute entry path",
},
{
name: "traversal entry",
entries: []fakeEntry{
{name: "../escape.txt", data: []byte("x")},
},
wantMsg: "traversal segment",
},
{
name: "symlink entry",
entries: []fakeEntry{
{name: "link", data: []byte("/etc/passwd"), typeflag: tar.TypeSymlink},
},
wantMsg: "non-regular entry",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
path := writeTarZst(t, tc.entries)
_, err := bnp.Verify(path)
if err == nil {
t.Fatalf("expected rejection, got nil")
}
if !strings.Contains(err.Error(), tc.wantMsg) {
t.Errorf("error %q does not mention %q", err.Error(), tc.wantMsg)
}
})
}
}

View File

@ -1,175 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"slices"
"sort"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newPluginTagsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tags",
Short: "Show current tags and popular tags from the registry",
RunE: func(c *cobra.Command, _ []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
if len(mod.Plugin.Tags) == 0 {
fmt.Println("Current tags: (none)")
} else {
fmt.Printf("Current tags: %s\n", strings.Join(mod.Plugin.Tags, ", "))
}
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return nil // not signed in is fine — silent best-effort
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return nil
}
cli := orchclient.New(resolvedHost, hc.Token)
line := fetchPopularTagsForList(cli, mod.Plugin.Kind)
fmt.Println(line)
return nil
},
}
cmd.AddCommand(&cobra.Command{
Use: "add <tag>...",
Short: "Add tags to the local plugin.mod (union with current)",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("add", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "rm <tag>...",
Short: "Remove tags from the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("rm", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "set <tag>...",
Short: "Replace all tags in the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("set", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Remove all tags from the local plugin.mod",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { return mutateTags("clear", nil) },
})
return cmd
}
// mutateTags reads plugin.mod, computes the new tag set, normalises, and writes
// it back. Prints the before→after diff and a reminder to publish.
func mutateTags(op string, args []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
before := append([]string(nil), mod.Plugin.Tags...)
var next []string
switch op {
case "add":
next = append(append([]string(nil), before...), args...)
case "rm":
drop := map[string]struct{}{}
for _, a := range args {
drop[strings.ToLower(strings.TrimSpace(a))] = struct{}{}
}
for _, t := range before {
if _, gone := drop[t]; !gone {
next = append(next, t)
}
}
case "set":
next = append([]string(nil), args...)
case "clear":
next = nil
default:
return fmt.Errorf("unknown tag op: %s", op)
}
normalised, err := core.NormalizeTags(next)
if err != nil {
return err
}
if err := writeLocalModTags(mod, normalised); err != nil {
return err
}
sortedBefore := append([]string(nil), before...)
sortedAfter := append([]string(nil), normalised...)
sort.Strings(sortedBefore)
sort.Strings(sortedAfter)
fmt.Printf("Tags: [%s] → [%s]\n", strings.Join(sortedBefore, ", "), strings.Join(sortedAfter, ", "))
if !slices.Equal(sortedBefore, sortedAfter) {
fmt.Println("Run 'ninja plugin publish' to push to the registry.")
}
return nil
}
func readLocalMod() (*core.ModFile, error) {
b, err := os.ReadFile("plugin.mod")
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(b)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
return mod, nil
}
// writeLocalModTags rewrites plugin.mod with the new tag set, preserving all
// other fields by reusing upsertPluginMod.
func writeLocalModTags(mod *core.ModFile, tags []string) error {
return upsertPluginMod(
mod.Plugin.Scope,
mod.Plugin.Name,
mod.Plugin.DisplayName,
mod.Plugin.Description,
mod.Plugin.Kind,
mod.Plugin.Categories,
tags,
mod.Plugin.Private,
)
}
// fetchPopularTagsForList returns a single user-facing line listing the most-used
// tags for the given kind. Renders "Popular: tag (count), ...", "Popular tags:
// (none yet)" when no tags exist on public plugins yet, or an "(unreachable)"
// notice if the RPC fails.
func fetchPopularTagsForList(cli *orchclient.Client, kind string) string {
resp, err := cli.Reg.ListTags(context.Background(), connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20}))
if err != nil {
return "(could not fetch popular tags — orchestrator unreachable)"
}
if len(resp.Msg.Tags) == 0 {
return "Popular tags: (none yet)"
}
parts := make([]string, len(resp.Msg.Tags))
for i, t := range resp.Msg.Tags {
parts[i] = fmt.Sprintf("%s (%d)", t.Tag, t.Count)
}
return "Popular: " + strings.Join(parts, ", ")
}

View File

@ -1,637 +0,0 @@
package cmd
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
core "git.dev.alexdunmow.com/block/core/plugin"
)
func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
err := checkRepoHasHEAD(dir)
if err == nil {
t.Fatal("expected error for repo with no commits, got nil")
}
if !strings.Contains(err.Error(), "no commits in repository") {
t.Errorf("error %q should mention 'no commits in repository'", err.Error())
}
if !strings.Contains(err.Error(), "git commit") {
t.Errorf("error %q should suggest `git commit`", err.Error())
}
}
func TestCheckRepoHasHEAD_WithCommitReturnsNil(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "f")
runGit(t, dir, "commit", "-qm", "init")
if err := checkRepoHasHEAD(dir); err != nil {
t.Errorf("expected nil for repo with a commit, got %v", err)
}
}
func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
}
func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "plugin.mod")
runGit(t, dir, "commit", "-qm", "seed")
beforeSHA := gitHeadSHA(t, dir)
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA != beforeSHA {
t.Errorf("expected no new commit, HEAD moved %s -> %s", beforeSHA, afterSHA)
}
}
func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
initialSHA := gitHeadSHA(t, dir)
runGit(t, dir, "checkout", "-q", initialSHA)
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err)
}
afterSHA := gitHeadSHA(t, dir)
if afterSHA == initialSHA {
t.Fatalf("expected new commit on detached HEAD, HEAD still at %s", afterSHA)
}
subject := gitLogSubject(t, dir)
if subject != "Add plugin.mod" {
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
}
parentCmd := exec.Command("git", "rev-parse", "HEAD^")
parentCmd.Dir = dir
parentOut, err := parentCmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse HEAD^: %v\n%s", err, parentOut)
}
parentSHA := strings.TrimSpace(string(parentOut))
if parentSHA != initialSHA {
t.Errorf("expected new commit parent to be %s, got %s", initialSHA, parentSHA)
}
}
func TestAutoCommitPluginMod_UsesProvidedMessage(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
if got := gitLogSubject(t, dir); got != "bump to 0.3.0" {
t.Errorf("expected commit subject 'bump to 0.3.0', got %q", got)
}
}
func TestAutoCommitPluginMod_LeavesOtherStagedPathsAlone(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
// Stage an unrelated change that publish should NOT sweep up into the
// plugin.mod auto-commit.
if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "other.txt")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err)
}
// The new commit should touch plugin.mod only.
filesCmd := exec.Command("git", "show", "--name-only", "--pretty=", "HEAD")
filesCmd.Dir = dir
filesOut, err := filesCmd.CombinedOutput()
if err != nil {
t.Fatalf("git show: %v\n%s", err, filesOut)
}
files := strings.Fields(strings.TrimSpace(string(filesOut)))
if len(files) != 1 || files[0] != "plugin.mod" {
t.Errorf("expected commit to touch only plugin.mod, got %v", files)
}
// other.txt should still be staged (waiting for the developer to deal with).
statusCmd := exec.Command("git", "status", "--porcelain", "other.txt")
statusCmd.Dir = dir
statusOut, _ := statusCmd.Output()
if !strings.HasPrefix(strings.TrimSpace(string(statusOut)), "A ") {
t.Errorf("expected other.txt to remain staged ('A '), got %q", string(statusOut))
}
}
func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Chdir(dir)
t.Setenv("PATH", "")
err := autoCommitPluginMod("Add plugin.mod")
if err == nil {
t.Fatal("expected error when git is missing from PATH, got nil")
}
if !strings.Contains(err.Error(), "git") {
t.Errorf("error %q should mention 'git'", err.Error())
}
}
func gitLogSubject(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "log", "-1", "--pretty=%s")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git log: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func gitHeadSHA(t *testing.T, dir string) string {
t.Helper()
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git rev-parse: %v\n%s", err, out)
}
return strings.TrimSpace(string(out))
}
func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("warning should list secret.env, got: %q", out)
}
if !strings.Contains(out, "git rm --cached") {
t.Errorf("warning should suggest `git rm --cached`, got: %q", out)
}
if !strings.HasSuffix(out, "\n") {
t.Errorf("warning should end with a newline (Fprintln), got: %q", out)
}
}
func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
gitignoredTrackedWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output for clean repo, got: %q", buf.String())
}
}
func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "notes.txt") {
t.Errorf("warning should list notes.txt, got: %q", out)
}
if !strings.Contains(out, "git add") {
t.Errorf("warning should suggest `git add`, got: %q", out)
}
}
func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
var buf bytes.Buffer
untrackedFilesWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
submoduleWarning(dir, &buf)
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("warning should mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("warning should mention submodules, got: %q", out)
}
}
func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
var buf bytes.Buffer
submoduleWarning(dir, &buf)
if buf.Len() != 0 {
t.Errorf("expected empty output, got: %q", buf.String())
}
}
func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "secret.env")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitignore")
runGit(t, dir, "commit", "-qm", "ignore")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "secret.env") {
t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out)
}
if !strings.Contains(out, "match .gitignore") {
t.Errorf("expected gitignored-tracked warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
gitmodules := `[submodule "vendor/foo"]
path = vendor/foo
url = https://example.com/foo.git
`
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", ".gitmodules")
runGit(t, dir, "commit", "-qm", "add submodule decl")
var buf bytes.Buffer
if err := emitPublishWarnings(dir, false, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "vendor/foo") {
t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out)
}
if !strings.Contains(out, "submodules") {
t.Errorf("expected submodule warning fragment, got: %q", out)
}
}
func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, "add", "README.md")
runGit(t, dir, "commit", "-qm", "init")
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) {
var buf bytes.Buffer
if err := emitPublishWarnings(dir, true, &buf); err != nil {
t.Fatalf("emitPublishWarnings: %v", err)
}
out := buf.String()
if !strings.Contains(out, "scratch.txt") {
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out)
}
if !strings.Contains(out, "NOT be in the archive") {
t.Errorf("expected untracked-files warning fragment, got: %q", out)
}
})
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
var buf bytes.Buffer
err := emitPublishWarnings(dir, false, &buf)
if err == nil {
t.Fatal("expected dirty-tree error, got nil")
}
if !strings.Contains(err.Error(), "working tree dirty") {
t.Errorf("expected dirty-tree error, got: %v", err)
}
if !strings.Contains(err.Error(), "--strict") {
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
}
if strings.Contains(buf.String(), "untracked files will NOT be in the archive") {
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
}
})
}
func TestWriteMod_PrivateTrueSerializes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plugin.mod")
m := &core.ModFile{Plugin: core.ModPlugin{
Name: "myplugin",
Scope: "themes",
Version: "0.1.0",
Private: true,
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(got), "private = true") {
t.Errorf("expected `private = true` line in plugin.mod, got:\n%s", got)
}
}
func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plugin.mod")
m := &core.ModFile{Plugin: core.ModPlugin{
Name: "publicthing",
Scope: "themes",
Version: "0.1.0",
}}
if err := writeMod(path, m); err != nil {
t.Fatalf("writeMod: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if strings.Contains(string(got), "private") {
t.Errorf("expected no `private` line, got:\n%s", got)
}
}
func TestParsePrivateCoord(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
{in: "myplugin", want: "myplugin"},
{in: "@private/myplugin", want: "myplugin"},
{in: " myplugin ", want: "myplugin"},
{in: "@themes/myplugin", wantErr: true},
{in: "@private", wantErr: true},
}
for _, c := range cases {
got, err := parsePrivateCoord(c.in)
if c.wantErr {
if err == nil {
t.Errorf("parsePrivateCoord(%q) = %q, want error", c.in, got)
}
continue
}
if err != nil {
t.Errorf("parsePrivateCoord(%q) err: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("parsePrivateCoord(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMutateTags_AddRmSetClear(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// Seed plugin.mod with no tags.
must(upsertPluginMod("themes", "darkpro", "Dark Pro", "Sleek dark theme", "theme", []string{}, nil, false))
// add
must(mutateTags("add", []string{"dark", "agency"}))
mod, err := readLocalMod()
must(err)
if len(mod.Plugin.Tags) != 2 || mod.Plugin.Tags[0] != "dark" || mod.Plugin.Tags[1] != "agency" {
t.Errorf("after add: %v", mod.Plugin.Tags)
}
// add (dedupe + normalise)
must(mutateTags("add", []string{"Agency", "Serif"}))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 3 {
t.Errorf("after dedupe add: %v", mod.Plugin.Tags)
}
// rm
must(mutateTags("rm", []string{"dark"}))
mod, _ = readLocalMod()
for _, tag := range mod.Plugin.Tags {
if tag == "dark" {
t.Errorf("after rm: dark still present: %v", mod.Plugin.Tags)
}
}
// set
must(mutateTags("set", []string{"editorial"}))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "editorial" {
t.Errorf("after set: %v", mod.Plugin.Tags)
}
// clear
must(mutateTags("clear", nil))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 0 {
t.Errorf("after clear: %v", mod.Plugin.Tags)
}
}
func TestMutateTags_RejectsInvalidNoWrite(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := upsertPluginMod("themes", "x", "X", "", "theme", nil, []string{"dark"}, false); err != nil {
t.Fatal(err)
}
if err := mutateTags("add", []string{"BAD SPACE"}); err == nil {
t.Fatal("expected validation error")
}
mod, err := readLocalMod()
if err != nil {
t.Fatal(err)
}
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "dark" {
t.Errorf("tags mutated despite error: %v", mod.Plugin.Tags)
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

View File

@ -1,21 +0,0 @@
package cmd
import "github.com/spf13/cobra"
func NewRoot() *cobra.Command {
root := &cobra.Command{
Use: "ninja",
Short: "BlockNinja developer CLI",
Long: "ninja is the developer-facing CLI for BlockNinja. First subcommand group: plugin.",
}
root.PersistentFlags().String("host", "", "Orchestrator base URL (default: from credentials or https://my.blockninjacms.com)")
root.AddCommand(newVersionCmd())
root.AddCommand(newLoginCmd())
root.AddCommand(newLogoutCmd())
root.AddCommand(newWhoamiCmd())
root.AddCommand(newPluginCmd())
root.AddCommand(newThemeCmd())
root.AddCommand(newScopeCmd())
root.AddCommand(newAccountCmd())
return root
}

View File

@ -1,230 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newScopeCmd() *cobra.Command {
c := &cobra.Command{Use: "scope", Short: "Manage plugin scopes"}
c.AddCommand(newScopeCreateCmd(), newScopeListCmd(), newScopeDefaultCmd())
return c
}
func newScopeCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create [scope]",
Short: "Create a new scope (organisation namespace for plugins)",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scanner := bufio.NewScanner(os.Stdin)
var slug string
if len(args) > 0 {
slug, err = parseScope(args[0])
if err != nil {
return err
}
} else {
slug, err = promptScopeSlug(scanner)
if err != nil {
return err
}
}
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
displayName := scopeAPISlug(slug)
if scanner.Scan() {
if v := strings.TrimSpace(scanner.Text()); v != "" {
displayName = v
}
}
_, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{
Slug: scopeAPISlug(slug),
DisplayName: displayName,
}))
if err != nil {
return err
}
fmt.Printf("Created scope %s\n", slug)
fmt.Printf("Set %s as your default scope? [Y/n]: ", slug)
if scanner.Scan() {
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
if ans == "" || ans == "y" || ans == "yes" {
hc.DefaultScope = slug
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err)
} else {
fmt.Println("Default scope saved.")
}
}
}
return nil
},
}
return cmd
}
func promptScopeSlug(scanner *bufio.Scanner) (string, error) {
fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).")
fmt.Println("It appears in plugin names like @acme/my-plugin.")
fmt.Println()
fmt.Print("Scope slug (lowercase letters, numbers, dashes): ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
return parseScope(scanner.Text())
}
func newScopeDefaultCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "default",
Short: "Show or change the default scope",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
_, hc, err := cr.Resolve(host)
if err != nil {
return err
}
if hc.DefaultScope == "" {
fmt.Println("No default scope set. Run: ninja scope default set")
} else {
fmt.Println(hc.DefaultScope)
}
return nil
},
}
cmd.AddCommand(newScopeDefaultSetCmd())
return cmd
}
func newScopeDefaultSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set",
Short: "Pick a default scope from your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
fmt.Println("Your scopes:")
for i, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (current)"
}
fmt.Printf(" %d. @%s — %s%s\n", i+1, s.Slug, s.DisplayName, marker)
}
fmt.Println()
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Select a scope: ")
if !scanner.Scan() {
return fmt.Errorf("cancelled")
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
return fmt.Errorf("cancelled")
}
var scope string
if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) {
scope = "@" + scopes.Msg.Scopes[n-1].Slug
} else {
return fmt.Errorf("invalid selection: %s", input)
}
hc.DefaultScope = scope
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Printf("Default scope set to %s\n", scope)
return nil
},
}
}
func newScopeListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
for _, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (default)"
}
fmt.Printf("@%s — %s%s\n", s.Slug, s.DisplayName, marker)
}
return nil
},
}
}

View File

@ -1,108 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/shot"
)
func newThemeCmd() *cobra.Command {
c := &cobra.Command{Use: "theme", Short: "Theme authoring helpers (preview screenshots)"}
c.AddCommand(newThemeScreenshotCmd())
return c
}
func newThemeScreenshotCmd() *cobra.Command {
var gallery, slug, out, mobileOut, themeName, waitSelector string
var mobile bool
var width, height int
cmd := &cobra.Command{
Use: "screenshot",
Short: "Render this theme's showcase page and write preview.png into the repo",
Long: `screenshot builds the gallery URL for this theme's showcase page
(rendered via the CMS render-only ?preview_template override), drives a headless
Chromium against it, and writes the captured PNG into the theme repo (preview.png,
git-tracked the source of truth a human may later replace).
The theme name defaults to plugin.mod's name in the current directory. Point
--gallery at the gallery CMS site that has this theme's .so loaded and the
showcase content seeded. --host selects the orchestrator (defaults to PROD); the
gallery URL is independent of --host but the flag is accepted for parity with
the rest of the CLI.`,
RunE: func(c *cobra.Command, _ []string) error {
if themeName == "" {
modBytes, err := os.ReadFile("plugin.mod")
if err != nil {
return fmt.Errorf("read plugin.mod (run from the theme repo, or pass --theme): %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return err
}
if mod.Plugin.Kind != "theme" {
return fmt.Errorf("plugin.mod kind = %q, want theme", mod.Plugin.Kind)
}
themeName = mod.Plugin.Name
}
if gallery == "" {
return fmt.Errorf("--gallery is required (the gallery CMS site base, e.g. https://showcase.localdev.blockninjacms.com)")
}
ctx := context.Background()
desktopURL := shot.PreviewURL(gallery, slug, themeName)
fmt.Fprintf(os.Stderr, "capturing desktop: %s\n", desktopURL)
png, err := shot.Capture(ctx, shot.Options{
URL: desktopURL,
Width: width,
Height: height,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
if err := os.WriteFile(out, png, 0o644); err != nil {
return fmt.Errorf("write %s: %w", out, err)
}
fmt.Printf("wrote %s (%d bytes)\n", out, len(png))
if mobile {
mURL := shot.PreviewURL(gallery, slug, themeName)
fmt.Fprintf(os.Stderr, "capturing mobile: %s\n", mURL)
mpng, err := shot.Capture(ctx, shot.Options{
URL: mURL,
Width: 390,
Height: 844,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
if err := os.WriteFile(mobileOut, mpng, 0o644); err != nil {
return fmt.Errorf("write %s: %w", mobileOut, err)
}
fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng))
}
return nil
},
}
cmd.Flags().StringVar(&gallery, "gallery", "", "Gallery CMS site base URL (has this theme loaded + showcase seeded)")
cmd.Flags().StringVar(&slug, "slug", "/", "Showcase page slug to capture")
cmd.Flags().StringVar(&out, "out", "preview.png", "Output path for the desktop screenshot (git-tracked in the theme repo)")
cmd.Flags().StringVar(&mobileOut, "mobile-out", "preview-mobile.png", "Output path for the mobile screenshot")
cmd.Flags().StringVar(&themeName, "theme", "", "Theme key to preview (default: plugin.mod name)")
cmd.Flags().StringVar(&waitSelector, "wait", "section", "CSS selector to wait for before capturing")
cmd.Flags().BoolVar(&mobile, "mobile", false, "Also capture preview-mobile.png at a phone viewport")
cmd.Flags().IntVar(&width, "width", 1440, "Desktop viewport width")
cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height")
return cmd
}

View File

@ -1,36 +0,0 @@
package cmd
import (
"strings"
"testing"
)
func TestThemeScreenshotCommandRegistered(t *testing.T) {
root := NewRoot()
theme, _, err := root.Find([]string{"theme", "screenshot"})
if err != nil {
t.Fatalf("find theme screenshot: %v", err)
}
if theme.Name() != "screenshot" {
t.Fatalf("resolved command = %q, want screenshot", theme.Name())
}
}
func TestThemeScreenshotHasGalleryAndMobileFlags(t *testing.T) {
root := NewRoot()
cmd, _, _ := root.Find([]string{"theme", "screenshot"})
for _, name := range []string{"gallery", "mobile", "out", "slug"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("missing --%s flag", name)
}
}
}
func TestThemeScreenshotDefaultOutIsPreviewPng(t *testing.T) {
root := NewRoot()
cmd, _, _ := root.Find([]string{"theme", "screenshot"})
out, _ := cmd.Flags().GetString("out")
if !strings.HasSuffix(out, "preview.png") {
t.Fatalf("default --out = %q, want it to end with preview.png", out)
}
}

View File

@ -1,19 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var Version = "dev"
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print ninja version",
Run: func(_ *cobra.Command, _ []string) {
fmt.Println("ninja", Version)
},
}
}

View File

@ -1,56 +0,0 @@
package archive
import (
"bytes"
"fmt"
"io"
"os/exec"
"strings"
"github.com/klauspost/compress/zstd"
)
// BuildSourceArchive captures the working tree as `tar.zst` bytes.
//
// When the working tree is clean it archives HEAD. When it's dirty
// (modified or staged tracked files), it archives a temporary stash
// object so the dirty state is what ships — callers that want
// HEAD-only behaviour should reject dirty trees before calling.
// Untracked files are never included regardless of state.
func BuildSourceArchive(repoDir string) ([]byte, error) {
stashCmd := exec.Command("git", "stash", "create")
stashCmd.Dir = repoDir
var stashOut, stashErr bytes.Buffer
stashCmd.Stdout = &stashOut
stashCmd.Stderr = &stashErr
if err := stashCmd.Run(); err != nil {
return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String())
}
treeish := "HEAD"
if sha := strings.TrimSpace(stashOut.String()); sha != "" {
treeish = sha
}
cmd := exec.Command("git", "archive", "--format=tar", treeish)
cmd.Dir = repoDir
var tarOut, stderr bytes.Buffer
cmd.Stdout = &tarOut
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git archive: %v: %s", err, stderr.String())
}
var compressed bytes.Buffer
enc, err := zstd.NewWriter(&compressed, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return nil, err
}
if _, err := io.Copy(enc, &tarOut); err != nil {
_ = enc.Close()
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return compressed.Bytes(), nil
}

View File

@ -1,208 +0,0 @@
package archive
import (
"archive/tar"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/klauspost/compress/zstd"
)
func TestBuildSourceArchive_RoundTrip(t *testing.T) {
dir := t.TempDir()
run := func(name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
}
run("git", "init", "-q")
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("nope"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.log\n"), 0o644); err != nil {
t.Fatal(err)
}
run("git", "add", "plugin.mod", ".gitignore")
run("git", "commit", "-qm", "init")
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
if len(zstdBytes) == 0 {
t.Fatal("empty archive")
}
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
if err != nil {
t.Fatal(err)
}
defer dec.Close()
tr := tar.NewReader(dec)
got := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
buf, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
got[hdr.Name] = string(buf)
}
if _, ok := got["plugin.mod"]; !ok {
t.Errorf("expected plugin.mod in archive, got %v", keys(got))
}
if _, ok := got["ignored.log"]; ok {
t.Errorf("ignored.log should not be in archive (gitignored + untracked)")
}
}
func keys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func TestBuildSourceArchive_DirtyTreeShipsWorkingCopy(t *testing.T) {
dir := t.TempDir()
runGitArchive(t, dir, "init", "-q")
modPath := filepath.Join(dir, "plugin.mod")
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitArchive(t, dir, "add", "plugin.mod")
runGitArchive(t, dir, "commit", "-qm", "init")
dirtyContents := []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n")
if err := os.WriteFile(modPath, dirtyContents, 0o644); err != nil {
t.Fatal(err)
}
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
got := readArchive(t, zstdBytes)
contents, ok := got["plugin.mod"]
if !ok {
t.Fatalf("expected plugin.mod in archive, got %v", keys(got))
}
if !strings.Contains(contents, `version="0.1.1"`) {
t.Errorf("archived plugin.mod should have dirty version 0.1.1, got: %q", contents)
}
if strings.Contains(contents, `version="0.1.0"`) {
t.Errorf("archived plugin.mod should NOT have HEAD version 0.1.0, got: %q", contents)
}
// Working tree should be unchanged after stash-create.
postContents, err := os.ReadFile(modPath)
if err != nil {
t.Fatal(err)
}
if string(postContents) != string(dirtyContents) {
t.Errorf("working tree mutated after BuildSourceArchive\nwant: %q\ngot: %q",
string(dirtyContents), string(postContents))
}
}
func TestBuildSourceArchive_DirtyTreeOmitsUntracked(t *testing.T) {
dir := t.TempDir()
runGitArchive(t, dir, "init", "-q")
modPath := filepath.Join(dir, "plugin.mod")
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitArchive(t, dir, "add", "plugin.mod")
runGitArchive(t, dir, "commit", "-qm", "init")
// Dirty the tracked file.
if err := os.WriteFile(modPath,
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n"), 0o644); err != nil {
t.Fatal(err)
}
// Add an untracked file (no git add).
if err := os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("not tracked"), 0o644); err != nil {
t.Fatal(err)
}
zstdBytes, err := BuildSourceArchive(dir)
if err != nil {
t.Fatalf("BuildSourceArchive: %v", err)
}
got := readArchive(t, zstdBytes)
if _, ok := got["extra.txt"]; ok {
t.Errorf("untracked extra.txt should not be in archive, got %v", keys(got))
}
}
func runGitArchive(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t",
"GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
func readArchive(t *testing.T, zstdBytes []byte) map[string]string {
t.Helper()
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
if err != nil {
t.Fatal(err)
}
defer dec.Close()
tr := tar.NewReader(dec)
got := map[string]string{}
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
buf, err := io.ReadAll(tr)
if err != nil {
t.Fatal(err)
}
got[hdr.Name] = string(buf)
}
return got
}

View File

@ -1,279 +0,0 @@
package bnp
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
core "git.dev.alexdunmow.com/block/core/plugin"
"google.golang.org/protobuf/proto"
)
// Required top-level artifact members (mirrors the CMS reader).
const (
fileWasm = "plugin.wasm"
fileMod = "plugin.mod"
fileManifest = "manifest.pb"
)
// minGoMajor/minGoMinor is the lowest Go toolchain that can emit reactor-mode
// (`-buildmode=c-shared`) wasip1 modules the guest shim relies on.
const (
minGoMajor = 1
minGoMinor = 24
)
// BuildOptions configures Build.
type BuildOptions struct {
// Dir is the plugin repo root (holds plugin.mod and the main Go package).
Dir string
// Output is the destination .bnp path. Empty → "<name>-<version>.bnp" in
// the current working directory.
Output string
}
// BuildResult summarizes a produced artifact for the CLI summary table.
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
UncompBytes int64 // sum of packed file sizes
BlockCount int
TemplateCount int
AdminPages int
JobTypes int
Hooks []string
DataDir bool
IncludedDirs []string
}
// Build compiles the plugin in opts.Dir to wasm, extracts its manifest, and
// packs a .bnp. No Docker/podman: the whole pipeline is the local Go
// toolchain + wazero + tar.zst.
func Build(ctx 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 (is %s a plugin repo?): %w", dir, 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 err := checkGoVersion(ctx); err != nil {
return nil, err
}
// 1. Compile to reactor-mode wasip1 c-shared.
tmp, err := os.MkdirTemp("", "ninja-build-*")
if err != nil {
return nil, fmt.Errorf("temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
wasmPath := filepath.Join(tmp, fileWasm)
if err := buildWasm(ctx, dir, wasmPath); err != nil {
return nil, err
}
// 2. Extract the manifest by driving DESCRIBE.
wasm, err := readWasm(wasmPath)
if err != nil {
return nil, err
}
manifest, err := ExtractManifest(ctx, wasm)
if err != nil {
return nil, fmt.Errorf("extract manifest: %w", err)
}
// 3. Validate against plugin.mod before packing an incoherent artifact.
if manifest.GetName() != mod.Plugin.Name {
return nil, fmt.Errorf("manifest name %q != plugin.mod name %q", manifest.GetName(), mod.Plugin.Name)
}
if v := manifest.GetAbiVersion(); v != hostAbiVersion {
return nil, fmt.Errorf("manifest abi_version %d unsupported (packer speaks %d)", v, hostAbiVersion)
}
// 4. Stamp the data_dir grant from plugin.mod. DESCRIBE cannot see
// plugin.mod (it lives outside guest code), so the packer is where the
// grant crosses from mod → manifest; the loader then reads one source.
manifest.DataDir = mod.Plugin.DataDir
// 4b. Mixed-form artifacts (WO-WZ-021): a reduced wasm plugin may keep a
// minimal guest for genuine logic (a contact route, Stripe) while its
// declarative surfaces (theme presets, bundled fonts, master pages,
// system/page templates, template overrides, email wrappers, css,
// required icon packs) live in an optional root manifest.yaml — exactly
// the codeless declarative set. Fold it into the DESCRIBE-derived
// manifest just as BuildCodeless does: the declarative keys
// supplement/override the guest's, and the referenced root JSON
// (presets.json / master_pages.json / fonts.json) is embedded into
// manifest.pb (not packed separately, same as codeless). The templates/
// dir carrying the .ninjatpl sources already rides the optional-dir
// pack below. A repo with NO manifest.yaml is a no-op here
// (applyManifestYAML returns nil on os.IsNotExist), so an existing
// pure-wasm plugin builds byte-for-byte as before.
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)
}
// 5. Assemble artifact entries.
entries := []packEntry{
{ArtifactPath: fileWasm, Source: wasmPath},
{ArtifactPath: fileMod, Source: modPath},
{ArtifactPath: fileManifest, Data: manifestBytes},
}
var includedDirs []string
// 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 {
return nil, dErr
}
if has {
entries = append(entries, dirEntries...)
includedDirs = append(includedDirs, o.artifact)
}
}
// 6. Pack.
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)
}
wasmInfo, _ := os.Stat(wasmPath)
return &BuildResult{
OutputPath: outPath,
Name: manifest.GetName(),
Version: manifest.GetVersion(),
WasmBytes: wasmInfo.Size(),
ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp,
BlockCount: len(manifest.GetBlocks()),
TemplateCount: len(manifest.GetTemplateKeys()),
AdminPages: len(manifest.GetAdminPages()),
JobTypes: len(manifest.GetJobTypes()),
Hooks: hooksPresent(manifest),
DataDir: manifest.GetDataDir(),
IncludedDirs: includedDirs,
}, nil
}
// buildWasm runs the reactor-mode wasip1 c-shared build in dir.
func buildWasm(ctx context.Context, dir, outPath string) error {
cmd := exec.CommandContext(ctx, "go", "build", "-buildmode=c-shared", "-o", outPath, ".")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go build (GOOS=wasip1 GOARCH=wasm -buildmode=c-shared) failed: %w\n%s", err, stderr.String())
}
return nil
}
var goVersionRe = regexp.MustCompile(`go(\d+)\.(\d+)(?:\.\d+)?`)
// checkGoVersion enforces the minimum toolchain (Go 1.24) with a clear error.
func checkGoVersion(ctx context.Context) error {
out, err := exec.CommandContext(ctx, "go", "version").Output()
if err != nil {
return fmt.Errorf("`go version` failed (is the Go toolchain on PATH?): %w", err)
}
m := goVersionRe.FindStringSubmatch(string(out))
if m == nil {
return fmt.Errorf("could not parse Go version from %q", string(out))
}
major, _ := strconv.Atoi(m[1])
minor, _ := strconv.Atoi(m[2])
if major < minGoMajor || (major == minGoMajor && minor < minGoMinor) {
return fmt.Errorf(
"building reactor-mode wasip1 plugins requires Go %d.%d+; found %d.%d — upgrade your toolchain",
minGoMajor, minGoMinor, major, minor)
}
return nil
}
// hooksPresent returns the sorted set of runtime hooks the manifest declares,
// for the summary table.
func hooksPresent(m *abiv1.PluginManifest) []string {
var hooks []string
if m.GetHasLoadHook() {
hooks = append(hooks, "load")
}
if m.GetHasUnloadHook() {
hooks = append(hooks, "unload")
}
if m.GetHasHttpHandler() {
hooks = append(hooks, "http")
}
if m.GetHasMediaHooks() {
hooks = append(hooks, "media")
}
if len(m.GetJobTypes()) > 0 {
hooks = append(hooks, "job")
}
if len(m.GetRagContentFetcherTypes()) > 0 {
hooks = append(hooks, "rag")
}
sort.Strings(hooks)
return hooks
}

View File

@ -1,515 +0,0 @@
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"`
SystemTemplates []struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
} `yaml:"system_templates"`
PageTemplates []struct {
System string `yaml:"system"`
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Slots []string `yaml:"slots"`
} `yaml:"page_templates"`
// TemplateOverrides declare theme-scoped overrides of builtin block
// rendering. Source convention: templates/overrides/<template>/<block>.ninjatpl,
// rendered host-side with the block's content map as the template context.
TemplateOverrides []struct {
Template string `yaml:"template"` // template/system key the override binds to
Block string `yaml:"block"` // builtin block key being overridden
} `yaml:"template_overrides"`
// EmailWrappers declare branded email wrappers per system key. Source
// convention: templates/email/<system>.ninjatpl, rendered host-side with
// body/colors/site/unsubscribe_url/preview_text in the context (emit the
// pre-rendered body with |safe).
EmailWrappers []string `yaml:"email_wrappers"`
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
}
// Only overwrite a field when manifest.yaml actually declares it. For a
// codeless build the manifest starts empty, so an undeclared key leaving the
// field at its proto zero is identical to the old unconditional assignment.
// For a mixed-form wasm build the manifest already carries the guest's
// DESCRIBE output, so these guards are what keep an undeclared key from
// WIPING a guest-populated field (theme_presets/bundled_fonts/etc.) —
// declarative keys supplement/override, they never clear (WO-WZ-021).
if my.ThemePresets != "" {
if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
return err
}
}
if my.BundledFonts != "" {
if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
return err
}
}
if my.SettingsSchema != "" {
if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
return err
}
}
if len(my.RequiredIconPacks) > 0 {
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,
})
}
for _, st := range my.SystemTemplates {
if st.Key == "" || st.Title == "" {
return fmt.Errorf("%s: system_templates entries need key and title", manifestYAMLName)
}
m.SystemTemplates = append(m.SystemTemplates, &abiv1.SystemTemplateMeta{
Key: st.Key, Title: st.Title, Description: st.Description,
})
}
for _, pt := range my.PageTemplates {
if pt.System == "" || pt.Key == "" || pt.Title == "" {
return fmt.Errorf("%s: page_templates entries need system, key, and title", manifestYAMLName)
}
// Layout source convention: templates/<system>/<key>.ninjatpl,
// rendered host-side (core/docs/codeless-bnp.md).
rel := filepath.Join(dirTemplates, pt.System, pt.Key+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: page template %s/%s: %w", manifestYAMLName, pt.System, pt.Key, err)
}
m.PageTemplates = append(m.PageTemplates, &abiv1.PageTemplateMeta{
SystemKey: pt.System, Key: pt.Key, Title: pt.Title,
Description: pt.Description, Slots: pt.Slots,
})
}
for _, to := range my.TemplateOverrides {
if to.Template == "" || to.Block == "" {
return fmt.Errorf("%s: template_overrides entries need template and block", manifestYAMLName)
}
rel := filepath.Join(dirTemplates, "overrides", to.Template, to.Block+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: template override %s/%s: %w", manifestYAMLName, to.Template, to.Block, err)
}
m.BlockTemplateOverrides = append(m.BlockTemplateOverrides, &abiv1.BlockTemplateOverride{
TemplateKey: to.Template, BlockKey: to.Block,
})
}
for _, ek := range my.EmailWrappers {
if ek == "" {
return fmt.Errorf("%s: email_wrappers entries must be non-empty system keys", manifestYAMLName)
}
rel := filepath.Join(dirTemplates, "email", ek+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: email wrapper %s: %w", manifestYAMLName, ek, err)
}
m.EmailWrapperSystemKeys = append(m.EmailWrapperSystemKeys, ek)
}
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
}

View File

@ -1,183 +0,0 @@
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" +
"system_templates:\n - key: fixture\n title: Fixture Theme\n" +
"page_templates:\n - system: fixture\n key: landing\n title: Landing\n slots: [main]\n" +
"template_overrides:\n - template: fixture\n block: heading\n" +
"email_wrappers: [fixture]\n",
"templates/fixture/landing.ninjatpl": `<main>{{ content }}</main>`,
"templates/overrides/fixture/heading.ninjatpl": `<h2 class="deco">{{ text }}</h2>`,
"templates/email/fixture.ninjatpl": `<table><tr><td>{{ body|safe }}</td></tr></table>`,
"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 len(m.GetSystemTemplates()) != 1 || len(m.GetPageTemplates()) != 1 || m.GetPageTemplates()[0].GetSystemKey() != "fixture" {
t.Errorf("templates = %v / %v", m.GetSystemTemplates(), m.GetPageTemplates())
}
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())
}
if len(m.GetBlockTemplateOverrides()) != 1 ||
m.GetBlockTemplateOverrides()[0].GetTemplateKey() != "fixture" ||
m.GetBlockTemplateOverrides()[0].GetBlockKey() != "heading" {
t.Errorf("template overrides = %v", m.GetBlockTemplateOverrides())
}
if len(m.GetEmailWrapperSystemKeys()) != 1 || m.GetEmailWrapperSystemKeys()[0] != "fixture" {
t.Errorf("email wrappers = %v", m.GetEmailWrapperSystemKeys())
}
}
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",
}},
{"override missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "template_overrides:\n - template: x\n block: heading\n",
}},
{"override missing block key", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "template_overrides:\n - template: x\n",
"templates/overrides/x/heading.ninjatpl": `<h1>{{ text }}</h1>`,
}},
{"email wrapper missing template file", map[string]string{
"plugin.mod": "[plugin]\nname = \"x\"\nversion = \"0.1.0\"\n",
"manifest.yaml": "email_wrappers: [x]\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)
}
}
}

View File

@ -1,171 +0,0 @@
// Package bnp implements `ninja plugin build` and `ninja plugin verify`: it
// compiles a plugin to reactor-mode wasm, extracts its static manifest by
// instantiating the module once and calling HOOK_DESCRIBE, packs the .bnp
// artifact (tar.zst), and re-runs the CMS reader's layout/name/abi/path/size
// checks standalone.
//
// The verify rules here are a deliberate, documented duplication of the CMS
// reader (cms backend/plugin/bnp/reader.go, WO-WZ-008): core cannot import the
// CMS module, so publishers get the same gate the loader applies. WO-WZ-010's
// integration suite keeps the two in lockstep.
package bnp
import (
"context"
"fmt"
"os"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"google.golang.org/protobuf/proto"
)
// hostAbiVersion is the ABI major version the packer speaks when driving
// DESCRIBE. It must equal core's guest AbiVersion / the reader's supported
// major (1) or the guest's symmetric version gate rejects the call.
const hostAbiVersion uint32 = 1
// ExtractManifest instantiates a reactor-mode plugin.wasm with wazero, drives
// one HOOK_DESCRIBE round trip, and returns the decoded PluginManifest.
//
// The host functions the guest imports (blockninja.host_call) are stubbed to
// FAIL: DESCRIBE is publish-time and must not need a live host. A plugin that
// reaches a host capability while its manifest is being extracted (e.g. a
// db.* call from Register) gets an actionable error naming the offending
// method rather than a mystery hang or nil deref.
func ExtractManifest(ctx context.Context, wasm []byte) (*abiv1.PluginManifest, error) {
r := wazero.NewRuntime(ctx)
defer func() { _ = r.Close(ctx) }()
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
return nil, fmt.Errorf("instantiate wasi: %w", err)
}
if err := installFailingHost(ctx, r); err != nil {
return nil, fmt.Errorf("install host stub: %w", err)
}
mod, err := r.InstantiateWithConfig(ctx, wasm,
wazero.NewModuleConfig().WithStartFunctions("_initialize").WithName("plugin"))
if err != nil {
return nil, fmt.Errorf("instantiate module (is it a reactor-mode wasip1 c-shared build?): %w", err)
}
for _, export := range []string{"bn_alloc", "bn_invoke", "bn_free"} {
if mod.ExportedFunction(export) == nil {
return nil, fmt.Errorf("module does not export %s — not a BlockNinja wasm guest (missing wasmguest.Serve boilerplate?)", export)
}
}
resp, err := invokeDescribe(ctx, mod)
if err != nil {
return nil, err
}
if e := resp.GetError(); e != nil {
return nil, fmt.Errorf("DESCRIBE failed (%s): %s", e.GetCode(), e.GetMessage())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
return nil, fmt.Errorf("decode DescribeResponse: %w", err)
}
m := dr.GetManifest()
if m == nil {
return nil, fmt.Errorf("DESCRIBE returned an empty manifest")
}
return m, nil
}
// invokeDescribe drives one bn_alloc / bn_invoke round trip for HOOK_DESCRIBE
// through guest linear memory, mirroring the calling convention in
// core/docs/wasm-abi.md.
func invokeDescribe(ctx context.Context, mod api.Module) (*abiv1.InvokeResponse, error) {
payload, err := proto.Marshal(&abiv1.DescribeRequest{HostAbiVersion: hostAbiVersion})
if err != nil {
return nil, fmt.Errorf("marshal DescribeRequest: %w", err)
}
req, err := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook_HOOK_DESCRIBE, Payload: payload})
if err != nil {
return nil, fmt.Errorf("marshal InvokeRequest: %w", err)
}
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(req)))
if err != nil {
return nil, fmt.Errorf("bn_alloc: %w", err)
}
ptr := uint32(res[0])
if ptr == 0 {
return nil, fmt.Errorf("bn_alloc returned 0")
}
if !mod.Memory().Write(ptr, req) {
return nil, fmt.Errorf("write DESCRIBE request out of range")
}
res, err = mod.ExportedFunction("bn_invoke").Call(ctx,
uint64(abiv1.Hook_HOOK_DESCRIBE), uint64(ptr), uint64(len(req)))
if err != nil {
return nil, fmt.Errorf("bn_invoke(DESCRIBE): %w", err)
}
packed := res[0]
if packed == 0 {
return nil, fmt.Errorf("bn_invoke returned packed 0 (guest could not produce a response envelope)")
}
respBytes, ok := mod.Memory().Read(uint32(packed>>32), uint32(packed))
if !ok {
return nil, fmt.Errorf("read DESCRIBE response out of range")
}
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(respBytes, resp); err != nil {
return nil, fmt.Errorf("decode InvokeResponse: %w", err)
}
_, _ = mod.ExportedFunction("bn_free").Call(ctx, uint64(ptr))
return resp, nil
}
// installFailingHost exports blockninja.host_call so the guest module can be
// instantiated, but answers every capability call with a PERMISSION_DENIED
// AbiError naming the method — DESCRIBE must not depend on the host.
func installFailingHost(ctx context.Context, r wazero.Runtime) error {
_, err := r.NewHostModuleBuilder("blockninja").
NewFunctionBuilder().
WithFunc(func(ctx context.Context, mod api.Module, ptr, size uint32) uint64 {
method := "<unknown>"
if data, ok := mod.Memory().Read(ptr, size); ok {
hcr := &abiv1.HostCallRequest{}
if proto.Unmarshal(data, hcr) == nil && hcr.GetMethod() != "" {
method = hcr.GetMethod()
}
}
out, err := proto.Marshal(&abiv1.HostCallResponse{
Error: &abiv1.AbiError{
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_PERMISSION_DENIED,
Message: fmt.Sprintf(
"capability %q is unavailable during manifest extraction (DESCRIBE): a plugin must not call host capabilities at register/describe time",
method),
},
})
if err != nil {
return 0
}
res, err := mod.ExportedFunction("bn_alloc").Call(ctx, uint64(len(out)))
if err != nil || len(res) == 0 || res[0] == 0 {
return 0
}
respPtr := uint32(res[0])
if !mod.Memory().Write(respPtr, out) {
return 0
}
return uint64(respPtr)<<32 | uint64(uint32(len(out)))
}).
Export("host_call").
Instantiate(ctx)
return err
}
// readWasm is a tiny helper so callers can pass a path.
func readWasm(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read wasm %s: %w", path, err)
}
return b, nil
}

View File

@ -1,79 +0,0 @@
package bnp
import (
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
)
// TestApplyManifestYAMLSupplementsWithoutWiping proves the WO-WZ-021 mixed-form
// builder semantics: folding manifest.yaml onto a manifest that already carries
// guest DESCRIBE output SUPPLEMENTS the declarative surfaces and OVERRIDES only
// the keys it actually declares — an undeclared scalar key never wipes a
// guest-populated field.
func TestApplyManifestYAMLSupplementsWithoutWiping(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, map[string]string{
"manifest.yaml": "" +
"bundled_fonts: fonts.json\n" +
"page_templates:\n" +
" - {system: blog, key: listing, title: Listing}\n" +
"email_wrappers: [newsletter]\n",
"fonts.json": `{"fonts":["Inter"]}`,
"templates/blog/listing.ninjatpl": `<main>{{ title }}</main>`,
"templates/email/newsletter.ninjatpl": `<table>{{ body|safe }}</table>`,
})
// Simulate a guest DESCRIBE result: theme_presets set by the guest, plus a
// guest-rendered page template the manifest.yaml does NOT list.
m := &abiv1.PluginManifest{
AbiVersion: 1,
Name: "fixture-mixed",
Version: "0.1.0",
ThemePresets: []byte(`{"guest":true}`),
PageTemplates: []*abiv1.PageTemplateMeta{
{SystemKey: "blog", Key: "guestonly", Title: "Guest Only"},
},
}
if err := applyManifestYAML(dir, m); err != nil {
t.Fatalf("applyManifestYAML: %v", err)
}
// Undeclared theme_presets must be PRESERVED, not wiped to nil.
if string(m.ThemePresets) != `{"guest":true}` {
t.Errorf("theme_presets wiped/altered: %q", string(m.ThemePresets))
}
// Declared bundled_fonts folded in.
if string(m.BundledFonts) != `{"fonts":["Inter"]}` {
t.Errorf("bundled_fonts = %q", string(m.BundledFonts))
}
// Declared page template appended alongside the guest one (2 total).
if len(m.PageTemplates) != 2 {
t.Fatalf("page templates = %d, want 2 (guest + declarative)", len(m.PageTemplates))
}
// Declared email wrapper folded in.
if len(m.EmailWrapperSystemKeys) != 1 || m.EmailWrapperSystemKeys[0] != "newsletter" {
t.Errorf("email wrappers = %v", m.EmailWrapperSystemKeys)
}
}
// TestWasmBuildFoldsManifestYAML end-to-end: a repo with Go source AND a
// manifest.yaml builds a (non-codeless) wasm artifact whose manifest.pb carries
// the folded declarative surfaces. The absence of a manifest.yaml is a no-op —
// covered by every existing wasm build test.
func TestApplyManifestYAMLNoFileIsNoop(t *testing.T) {
dir := t.TempDir() // no manifest.yaml
m := &abiv1.PluginManifest{
AbiVersion: 1,
Name: "fixture-pure",
Version: "0.1.0",
ThemePresets: []byte(`{"guest":true}`),
}
if err := applyManifestYAML(dir, m); err != nil {
t.Fatalf("applyManifestYAML (no file): %v", err)
}
if string(m.ThemePresets) != `{"guest":true}` {
t.Errorf("no-manifest.yaml build mutated the manifest: %q", string(m.ThemePresets))
}
}

View File

@ -1,138 +0,0 @@
package bnp
import (
"archive/tar"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/klauspost/compress/zstd"
)
// packEntry is one file destined for the artifact: its path inside the .bnp
// (always forward-slash relative) and either literal Data or a Source file to
// stream from.
type packEntry struct {
ArtifactPath string
Data []byte
Source string
}
// packArtifact writes entries as a zstd-compressed tar to outPath. Entries are
// sorted by artifact path for a deterministic archive. Directory entries are
// synthesized as needed so the reader's dirExists checks fire for
// migrations/, schemas/, assets/, web/.
func packArtifact(outPath string, entries []packEntry) (int64, error) {
sort.Slice(entries, func(i, j int) bool { return entries[i].ArtifactPath < entries[j].ArtifactPath })
out, err := os.Create(outPath)
if err != nil {
return 0, fmt.Errorf("create %s: %w", outPath, err)
}
defer func() { _ = out.Close() }()
enc, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.SpeedDefault))
if err != nil {
return 0, err
}
tw := tar.NewWriter(enc)
seenDir := map[string]bool{}
writeDir := func(dir string) error {
if dir == "" || dir == "." || seenDir[dir] {
return nil
}
seenDir[dir] = true
return tw.WriteHeader(&tar.Header{
Name: dir + "/",
Typeflag: tar.TypeDir,
Mode: 0o755,
})
}
var total int64
for _, e := range entries {
name := filepath.ToSlash(e.ArtifactPath)
// Ensure parent directories exist as explicit entries.
for _, d := range parentDirs(name) {
if err := writeDir(d); err != nil {
return 0, fmt.Errorf("write dir header %q: %w", d, err)
}
}
var data []byte
if e.Source != "" {
data, err = os.ReadFile(e.Source)
if err != nil {
return 0, fmt.Errorf("read %s: %w", e.Source, err)
}
} else {
data = e.Data
}
if err := tw.WriteHeader(&tar.Header{
Name: name,
Typeflag: tar.TypeReg,
Mode: 0o644,
Size: int64(len(data)),
}); err != nil {
return 0, fmt.Errorf("write header %q: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return 0, fmt.Errorf("write %q: %w", name, err)
}
total += int64(len(data))
}
if err := tw.Close(); err != nil {
return 0, fmt.Errorf("close tar: %w", err)
}
if err := enc.Close(); err != nil {
return 0, fmt.Errorf("close zstd: %w", err)
}
return total, nil
}
// parentDirs returns the ancestor directories of a forward-slash path, from
// shallowest to deepest ("a/b/c.txt" → ["a", "a/b"]).
func parentDirs(name string) []string {
var dirs []string
parts := strings.Split(name, "/")
for i := 1; i < len(parts); i++ {
dirs = append(dirs, strings.Join(parts[:i], "/"))
}
return dirs
}
// collectDir returns pack entries for every regular file under root, mapped
// under artifactPrefix. Symlinks and irregular files are skipped. Returns
// (entries, hadFiles).
func collectDir(root, artifactPrefix string) ([]packEntry, bool, error) {
info, err := os.Stat(root)
if err != nil || !info.IsDir() {
return nil, false, nil //nolint:nilerr // absent optional dir is not an error
}
var entries []packEntry
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
entries = append(entries, packEntry{
ArtifactPath: artifactPrefix + "/" + filepath.ToSlash(rel),
Source: path,
})
return nil
})
if walkErr != nil {
return nil, false, fmt.Errorf("walk %s: %w", root, walkErr)
}
return entries, len(entries) > 0, nil
}

View File

@ -1,329 +0,0 @@
package bnp
import (
"archive/tar"
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/klauspost/compress/zstd"
"google.golang.org/protobuf/proto"
)
// These constants and the validation flow below are a DELIBERATE duplication
// of the CMS reader (cms backend/plugin/bnp/reader.go, WO-WZ-008). core cannot
// import the CMS module, so `ninja plugin verify` re-implements the same gate a
// publisher's artifact will meet at load time. WO-WZ-010's integration suite
// keeps the two copies in lockstep; change both together.
const (
// supportedABIMajor is the ABI major version a host understands.
supportedABIMajor uint32 = 1
// maxArtifactBytes caps total decompressed size (matches the registry
// publish cap, 1 GiB) to bound a decompression bomb.
maxArtifactBytes int64 = 1 << 30
// maxEntryBytes caps a single extracted file.
maxEntryBytes int64 = 512 * 1024 * 1024
)
// VerifyResult reports what a valid artifact contains.
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:
// path-safety + size caps on extraction, required members present, manifest
// decodes, abi major supported, non-empty name, and manifest name == plugin.mod
// name. The temp dir is always cleaned up. A named error is returned for each
// malformed class.
func Verify(bnpPath string) (*VerifyResult, error) {
destDir, err := os.MkdirTemp("", "ninja-verify-*")
if err != nil {
return nil, fmt.Errorf("bnp: temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(destDir) }()
f, err := os.Open(bnpPath)
if err != nil {
return nil, fmt.Errorf("bnp: open %s: %w", bnpPath, err)
}
defer func() { _ = f.Close() }()
if err := extractTarZst(f, destDir); err != nil {
return nil, err
}
for _, req := range []string{fileMod, fileManifest} {
if !fileRegular(filepath.Join(destDir, req)) {
return nil, fmt.Errorf("bnp: artifact missing required %s", req)
}
}
manifestBytes, err := os.ReadFile(filepath.Join(destDir, fileManifest))
if err != nil {
return nil, fmt.Errorf("bnp: read manifest.pb: %w", err)
}
manifest := &abiv1.PluginManifest{}
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)
}
name := manifest.GetName()
if name == "" {
return nil, errors.New("bnp: manifest has empty name")
}
modBytes, err := os.ReadFile(filepath.Join(destDir, fileMod))
if err != nil {
return nil, fmt.Errorf("bnp: read plugin.mod: %w", err)
}
modName := parseModName(modBytes)
if modName == "" {
return nil, errors.New("bnp: plugin.mod has no name")
}
if modName != name {
return nil, fmt.Errorf("bnp: manifest name %q != plugin.mod name %q", name, modName)
}
return &VerifyResult{
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:
return viol("guest-rendered blocks/template keys (codeless blocks live in blocks/blocks.yaml; layouts are declared system/page templates rendered host-side)")
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.
func ReadManifest(bnpPath string) (*abiv1.PluginManifest, error) {
destDir, err := os.MkdirTemp("", "ninja-manifest-*")
if err != nil {
return nil, fmt.Errorf("bnp: temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(destDir) }()
f, err := os.Open(bnpPath)
if err != nil {
return nil, fmt.Errorf("bnp: open %s: %w", bnpPath, err)
}
defer func() { _ = f.Close() }()
if err := extractTarZst(f, destDir); err != nil {
return nil, err
}
b, err := os.ReadFile(filepath.Join(destDir, fileManifest))
if err != nil {
return nil, fmt.Errorf("bnp: read manifest.pb: %w", err)
}
m := &abiv1.PluginManifest{}
if err := proto.Unmarshal(b, m); err != nil {
return nil, fmt.Errorf("bnp: decode manifest.pb: %w", err)
}
return m, nil
}
// extractTarZst streams a zstd-compressed tar into destDir, rejecting
// non-regular entries, absolute/traversal paths, and enforcing size caps.
func extractTarZst(r io.Reader, destDir string) error {
zr, err := zstd.NewReader(r)
if err != nil {
return fmt.Errorf("bnp: open zstd stream: %w", err)
}
defer zr.Close()
tr := tar.NewReader(zr)
var total int64
root, err := filepath.Abs(destDir)
if err != nil {
return fmt.Errorf("bnp: resolve dest: %w", err)
}
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("bnp: read tar: %w", err)
}
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeDir:
default:
return fmt.Errorf("bnp: rejected non-regular entry %q (type %d)", hdr.Name, hdr.Typeflag)
}
target, err := safeJoin(root, hdr.Name)
if err != nil {
return err
}
if hdr.Typeflag == tar.TypeDir {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("bnp: mkdir %q: %w", hdr.Name, err)
}
continue
}
if hdr.Size > maxEntryBytes {
return fmt.Errorf("bnp: entry %q exceeds per-file cap (%d > %d)", hdr.Name, hdr.Size, maxEntryBytes)
}
if total+hdr.Size > maxArtifactBytes {
return fmt.Errorf("bnp: artifact exceeds total size cap (%d)", maxArtifactBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("bnp: mkdir for %q: %w", hdr.Name, err)
}
n, err := writeExtractedFile(target, io.LimitReader(tr, maxArtifactBytes-total))
if err != nil {
return fmt.Errorf("bnp: extract %q: %w", hdr.Name, err)
}
total += n
if total > maxArtifactBytes {
return fmt.Errorf("bnp: artifact exceeds total size cap (%d)", maxArtifactBytes)
}
}
return nil
}
// safeJoin joins a relative tar entry onto root, rejecting absolute paths and
// traversal segments.
func safeJoin(root, name string) (string, error) {
norm := strings.ReplaceAll(name, `\`, "/")
if norm == "" || norm == "." {
return "", fmt.Errorf("bnp: empty entry name %q", name)
}
if filepath.IsAbs(name) || strings.HasPrefix(norm, "/") {
return "", fmt.Errorf("bnp: absolute entry path %q rejected", name)
}
if slices.Contains(strings.Split(norm, "/"), "..") {
return "", fmt.Errorf("bnp: entry %q contains a traversal segment", name)
}
target := filepath.Join(root, filepath.Clean(norm))
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("bnp: entry %q escapes artifact root", name)
}
return target, nil
}
func writeExtractedFile(path string, r io.Reader) (int64, error) {
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return 0, err
}
n, err := io.Copy(out, r)
closeErr := out.Close()
if err != nil {
return n, err
}
return n, closeErr
}
func fileRegular(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.Mode().IsRegular()
}
func dirExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.IsDir()
}
// parseModName extracts the `name = "..."` value from a plugin.mod body,
// mirroring the reader's tolerant line scan (works whether or not the key sits
// under a [plugin] table).
func parseModName(data []byte) string {
sc := bufio.NewScanner(bytes.NewReader(data))
for sc.Scan() {
after, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "name")
if !ok {
continue
}
val, ok := strings.CutPrefix(strings.TrimSpace(after), "=")
if !ok {
continue
}
val = strings.Trim(strings.TrimSpace(val), `"`)
if val != "" {
return val
}
}
return ""
}

View File

@ -1,88 +0,0 @@
package creds
import (
"encoding/json"
"errors"
"os"
"path/filepath"
)
type Credentials struct {
DefaultHost string `json:"default_host"`
Hosts map[string]HostCreds `json:"hosts"`
}
type HostCreds struct {
Token string `json:"token"`
User string `json:"user,omitempty"`
DefaultScope string `json:"default_scope,omitempty"`
// ActiveAccountID is the orchestrator-side UUID of the account that
// account-scoped commands (notably `ninja plugins publish --private`)
// operate against. Set during `ninja login` (forced selection when the
// user belongs to more than one account) and changeable via
// `ninja account set`.
ActiveAccountID string `json:"active_account_id,omitempty"`
// ActiveAccountSlug mirrors ActiveAccountID in human-readable form for
// display in CLI output. The orchestrator-side slug is authoritative;
// the CLI refreshes it whenever it talks to the server.
ActiveAccountSlug string `json:"active_account_slug,omitempty"`
}
func filePath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ninja", "credentials.json"), nil
}
func Load() (*Credentials, error) {
p, err := filePath()
if err != nil {
return nil, err
}
b, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
return &Credentials{Hosts: map[string]HostCreds{}}, nil
}
if err != nil {
return nil, err
}
c := &Credentials{Hosts: map[string]HostCreds{}}
if err := json.Unmarshal(b, c); err != nil {
return nil, err
}
return c, nil
}
func (c *Credentials) Save() error {
p, err := filePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(p, b, 0o600)
}
func (c *Credentials) Resolve(host string) (string, HostCreds, error) {
if host == "" {
host = c.DefaultHost
}
if host == "" {
host = "https://my.blockninjacms.com"
}
if t := os.Getenv("NINJA_TOKEN"); t != "" {
return host, HostCreds{Token: t}, nil
}
hc, ok := c.Hosts[host]
if !ok || hc.Token == "" {
return host, HostCreds{}, errors.New("not logged in; run `ninja login`")
}
return host, hc, nil
}

View File

@ -1,45 +0,0 @@
package creds
import (
"encoding/json"
"testing"
)
func TestHostCreds_ActiveAccountRoundTrip(t *testing.T) {
src := HostCreds{
Token: "tok",
ActiveAccountID: "acct-uuid",
ActiveAccountSlug: "acme",
}
b, err := json.Marshal(src)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var got HostCreds
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.ActiveAccountID != "acct-uuid" {
t.Errorf("ActiveAccountID = %q, want acct-uuid", got.ActiveAccountID)
}
if got.ActiveAccountSlug != "acme" {
t.Errorf("ActiveAccountSlug = %q, want acme", got.ActiveAccountSlug)
}
}
func TestHostCreds_LegacyFileLoadsWithoutAccount(t *testing.T) {
// A creds.json from before the active-account fields existed must still
// unmarshal cleanly; the new fields should be zero-valued.
legacy := `{"token":"tok","user":"alice","default_scope":"@themes"}`
var got HostCreds
if err := json.Unmarshal([]byte(legacy), &got); err != nil {
t.Fatalf("Unmarshal legacy: %v", err)
}
if got.Token != "tok" {
t.Errorf("Token = %q", got.Token)
}
if got.ActiveAccountID != "" || got.ActiveAccountSlug != "" {
t.Errorf("ActiveAccount* should be empty for legacy file, got id=%q slug=%q",
got.ActiveAccountID, got.ActiveAccountSlug)
}
}

View File

@ -1,42 +0,0 @@
package orchclient
import (
"context"
"net/http"
"connectrpc.com/connect"
"git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1/orchestratorv1connect"
)
type Client struct {
Host string
Token string
Auth orchestratorv1connect.PluginAuthServiceClient
Scope orchestratorv1connect.PluginScopeServiceClient
Reg orchestratorv1connect.PluginRegistryServiceClient
Pub orchestratorv1connect.PluginPublishServiceClient
}
func New(host, token string) *Client {
httpClient := &http.Client{}
opts := []connect.ClientOption{
connect.WithInterceptors(bearerInterceptor(token)),
}
c := &Client{Host: host, Token: token}
c.Auth = orchestratorv1connect.NewPluginAuthServiceClient(httpClient, host, opts...)
c.Scope = orchestratorv1connect.NewPluginScopeServiceClient(httpClient, host, opts...)
c.Reg = orchestratorv1connect.NewPluginRegistryServiceClient(httpClient, host, opts...)
c.Pub = orchestratorv1connect.NewPluginPublishServiceClient(httpClient, host, opts...)
return c
}
func bearerInterceptor(token string) connect.Interceptor {
return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
if token != "" {
req.Header().Set("Authorization", "Bearer "+token)
}
return next(ctx, req)
}
})
}

View File

@ -1,87 +0,0 @@
// Package shot captures a PNG screenshot of a rendered CMS page using a
// headless Chromium (chromedp / CDP). It is the image-capture half of the
// `ninja theme screenshot` harness; dojo's Playwright runner only ingests
// pass/fail JSON, so capture lives here.
package shot
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/chromedp/chromedp"
)
// Options configures a single screenshot capture.
type Options struct {
URL string // full page URL to navigate to
Width int // viewport width in px
Height int // viewport height in px
FullPage bool // capture the full scroll height, not just the viewport
WaitSelector string // CSS selector to wait for before capturing (e.g. "section")
Timeout time.Duration // overall navigation+capture timeout
}
// PreviewURL composes the gallery-page URL with the render-only override.
// host is the gallery site base (scheme+host), slug is the page path ("/"),
// theme is the bare theme key written to ?preview_template.
func PreviewURL(host, slug, theme string) string {
host = strings.TrimRight(host, "/")
if slug == "" {
slug = "/"
}
if !strings.HasPrefix(slug, "/") {
slug = "/" + slug
}
return fmt.Sprintf("%s%s?preview_template=%s", host, slug, url.QueryEscape(theme))
}
// Capture navigates to opts.URL in a headless Chromium and returns PNG bytes.
func Capture(ctx context.Context, opts Options) ([]byte, error) {
if opts.Width == 0 {
opts.Width = 1440
}
if opts.Height == 0 {
opts.Height = 900
}
if opts.Timeout == 0 {
opts.Timeout = 30 * time.Second
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx,
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.WindowSize(opts.Width, opts.Height),
chromedp.Flag("headless", true),
)...,
)
defer cancelAlloc()
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
defer cancelBrowser()
timeoutCtx, cancelTimeout := context.WithTimeout(browserCtx, opts.Timeout)
defer cancelTimeout()
var buf []byte
tasks := chromedp.Tasks{
chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)),
chromedp.Navigate(opts.URL),
}
if opts.WaitSelector != "" {
tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery))
} else {
tasks = append(tasks, chromedp.WaitReady("body", chromedp.ByQuery))
}
if opts.FullPage {
tasks = append(tasks, chromedp.FullScreenshot(&buf, 90))
} else {
tasks = append(tasks, chromedp.CaptureScreenshot(&buf))
}
if err := chromedp.Run(timeoutCtx, tasks); err != nil {
return nil, fmt.Errorf("capture %s: %w", opts.URL, err)
}
return buf, nil
}

View File

@ -1,27 +0,0 @@
package shot
import "testing"
func TestPreviewURLComposesQuery(t *testing.T) {
got := PreviewURL("https://showcase.localdev.blockninjacms.com", "/", "gotham")
want := "https://showcase.localdev.blockninjacms.com/?preview_template=gotham"
if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want)
}
}
func TestPreviewURLTrimsTrailingSlashHost(t *testing.T) {
got := PreviewURL("https://showcase.localdev.blockninjacms.com/", "/", "noir")
want := "https://showcase.localdev.blockninjacms.com/?preview_template=noir"
if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want)
}
}
func TestPreviewURLNonRootSlug(t *testing.T) {
got := PreviewURL("https://x.example.com", "/showcase", "lcars")
want := "https://x.example.com/showcase?preview_template=lcars"
if got != want {
t.Fatalf("PreviewURL = %q, want %q", got, want)
}
}

View File

@ -1,15 +0,0 @@
package main
import (
"fmt"
"os"
"git.dev.alexdunmow.com/block/core/cmd/ninja/cmd"
)
func main() {
if err := cmd.NewRoot().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}

View File

@ -1,99 +0,0 @@
# ABI Capability Surface (WO-WZ-019)
The canonical map of **every capability a plugin needs from the CMS** to its
ABI mechanism. The rule is *injection, not reliance*: a plugin obtains 100% of
its host interactions by (a) declaring static intent in `manifest.pb`, (b)
calling a `blockninja.host_call` capability method, or (c) implementing a
host-invoked hook. Nothing requires linking the Go `core` module — the Go
guest SDK (`plugin/wasmguest`) is an ergonomic front-end over these
primitives, not part of the model. A plugin in any language that speaks
protobuf over the [wasm ABI](wasm-abi.md) reaches everything below.
Legend — **Mechanism**: `manifest` (static declaration, consumed by the host
loader), `host_call` (dynamic guest→host capability), `hook` (host-invoked
guest logic), `artifact` (a directory in the `.bnp` the host consumes).
**Codeless equivalent**: how a plugin with NO `plugin.wasm` gets the same
effect (the WO-WZ-020 declarative path; rows marked *WZ-020* land there).
## Static contributions (what the plugin IS)
| Capability | Mechanism | Status | Codeless equivalent |
|---|---|---|---|
| Register block types (key/title/category/icon/schema) | `manifest.blocks` (`BlockMeta`) + `schemas/` artifact | ABI-native | Same — pure declaration, zero code |
| Block template overrides | `manifest.block_template_overrides` | ABI-native | Same |
| Templates / system templates / page templates | `manifest.template_keys`, `system_templates`, `page_templates` | ABI-native | Same + `.ninjatpl` files in the artifact (WZ-020) |
| Master pages | `manifest.master_pages` | ABI-native | Same |
| Email wrappers | `manifest.email_wrapper_system_keys` | ABI-native | Same |
| Template tags / filters (computing) | `manifest.declared_tags` / `declared_filters` + `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` | ABI-native | N/A — a *computing* tag requires code by definition |
| Template tags (pure snippet/partial, no logic) | today: same as computing (hook) | **WZ-020**: declare as a template partial, host-rendered, no hook | Partial file in `templates/` |
| Powered block with a *declared data provider* (no render hook at all) | today: `HOOK_RENDER_BLOCK` returning `RenderBlockResponse.powered` | **WZ-020**: manifest provider declaration (`posts`, `pages`, `menu`, `datasource:<id>`, …) + `.ninjatpl`; host fetches + renders | The WZ-020 headline feature |
| Settings schema / settings panel / admin pages | `manifest.settings_schema`, `settings_panel`, `admin_pages` | ABI-native | Same |
| Theme presets / bundled fonts / CSS manifest / icon packs | `manifest.theme_presets`, `bundled_fonts`, `css_manifest`, `required_icon_packs` | ABI-native (JSON payloads; no Go types on the wire) | Same. Fonts are **purely declarative** — the host consumes `bundled_fonts` at load; runtime *activation* (choosing a font) is a site setting, writable via `settings.update_site_setting`. |
| AI actions | `manifest.ai_actions` | ABI-native | Same |
| RBAC method roles / core service bindings | `manifest.rbac_method_roles`, `core_service_bindings` | ABI-native | Same (bindings host-mounted) |
| Job types / RAG fetcher types / directory extensions (static fields) | `manifest.job_types`, `rag_content_fetcher_types`, `directory_extensions` | ABI-native | Job/RAG types need their hooks → code |
| Migrations / assets / MF web bundle | `artifact` `migrations/`, `assets/`, `web/` (host-run/served) | ABI-native | Same |
| Persistent data dir | `plugin.mod data_dir``manifest.data_dir` | ABI-native | N/A (no code to need it) |
## Dynamic requests (host_call families)
Every family below is registered in the cms host (`plugin/wasmhost/caps`) and
mirrored 1:1 by a guest SDK stub. Wire types are language-neutral: canonical
UUID strings, JSON bytes for maps, protobuf for structure.
| Capability | Methods | Status |
|---|---|---|
| Content reads | `content.get_author_profile`, `get_page`, `get_post`, `list_posts`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | ABI-native |
| **Content authoring** (WZ-019) | `content.create_page`, `set_page_blocks`, `publish_page`, `set_page_seo`, `upsert_post` | ABI-native |
| **Provisioning** (WZ-019) — idempotent seed/ensure, **Load-time** | `provisioner.ensure_data_table`, `merge_site_settings`, `ensure_setting`, `ensure_page`, `override_site_settings`, `ensure_menu_item`, `register_embedding_config`, `ensure_embed`, `ensure_job_schedule`, `update_data_table_row_field`, `disable_orphaned_job_schedules`, `ensure_plugin`, `ensure_custom_color`, `ensure_media` | ABI-native. `RegisterWithProvisioner` is **inert under wasm** (DESCRIBE stubs host functions): call `deps.Provisioner` from `Load` instead. `EnsureEmbed` crosses template-rendered embeds only — `RenderFunc` cannot serialize. |
| Settings | `settings.get_site_settings`, `get_plugin_settings`, `update_site_setting`, **`update_plugin_settings`** (WZ-019; host-pinned to the caller's own plugin) | ABI-native |
| Menus (read) | `menus.get_menu_by_name`, `get_menu_items` | ABI-native. Menu *writes* are seed-shaped → `provisioner.ensure_menu_item`. |
| Media | `media.deposit` (full pipeline, deterministic-ID + `created` flag, folder, alt text, stable `media:<uuid>` ref) + `provisioner.ensure_media` (immutable seed semantics) | ABI-native |
| Gating / crypto / datasources / users / subscriptions | as in [wasm-abi.md](wasm-abi.md) §Capability calls | ABI-native |
| Email | `email.send` | ABI-native |
| AI | `ai.text_call`, `ai.tools.register` (+ execution via `HOOK_AI_TOOL_CALL`) | ABI-native |
| Jobs | `jobs.submit`, **`jobs.progress`** (WZ-019; correlated to the running job via the HOOK_JOB call context — no job ID on the wire) | ABI-native |
| Bridge | `bridge.register_service`, `get_service` (availability), **`bridge.invoke`** (WZ-019; opaque-payload cross-plugin calls → provider's `BridgeInvokable` or `HOOK_BRIDGE_CALL`) | ABI-native. Typed in-process Go values still don't cross — by design; `GetService` stays nil for wasm consumers. |
| Embeddings / RAG / reviews / badges | as in wasm-abi.md | ABI-native |
| Database | `db.query/exec/tx_*` under the per-plugin Postgres role | ABI-native |
## Host-invoked hooks (logic the plugin injects)
| Hook | Contract | Status |
|---|---|---|
| `HOOK_DESCRIBE` | `DescribeRequest/Response` — manifest capture at publish | ABI-native |
| `HOOK_LOAD` / `HOOK_UNLOAD` | `LoadRequest` (host config) / `UnloadRequest` | ABI-native |
| `HOOK_RENDER_BLOCK` | html OR powered `{template, data_json}` | ABI-native |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest/Response` | ABI-native |
| `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` | declared-tag/filter callbacks, re-entrancy-free | ABI-native |
| `HOOK_HANDLE_HTTP` | buffered HTTP + trusted identity headers | ABI-native |
| `HOOK_JOB` | `JobRequest/Response` + `jobs.progress` | ABI-native |
| `HOOK_RAG_FETCH` | content re-index callback | ABI-native |
| `HOOK_MEDIA_HOOK` | media lifecycle events | ABI-native |
| **`HOOK_AI_TOOL_CALL`** (WZ-019) | `AiToolCallRequest{slug, params_json}``{content, error_message}`; executes the guest handler recorded at `ai.tools.register` | ABI-native |
| **`HOOK_BRIDGE_CALL`** (WZ-019) | `BridgeCallRequest{service_name, method, payload}``{payload}`; provider side of `bridge.invoke` | ABI-native |
| **`HOOK_DIRECTORY_PANEL_SECTION`** / **`HOOK_DIRECTORY_PIN_DECORATOR`** (WZ-019) | indexed callbacks over `manifest.directory_extensions` counts; pin decorators return the mutated pin | ABI-native |
**Per-instance rule for callback hooks:** anything the host may call back on
an arbitrary pooled instance (AI tools, bridge services, RAG fetchers) must be
registered in `Register` — which runs on every instance — not in `Load`
(one instance only). Use `wasmguest.HostServices()` for deps at Register time.
## Remaining open items
- **Slot / media / embed resolvers during a guest render**`RenderContext`
still cannot carry them (function values). The powered-block path sidesteps
this: host-side rendering has the real resolvers. Only a plain-HTML guest
block that wants to resolve `media:` refs itself is affected. Tracked for a
render-focused WO; not blocking any shipping plugin.
## Conformance
- Guest↔host wire parity: every capability method has a golden
request/response pair (`plugin/wasmguest/caps/testdata/golden/`, generated
by the guest stubs) that the cms host replays byte-for-byte through its real
handlers (`cms plugin/wasmhost/caps` `TestGoldenParity`). The count check
fails if a registered method lacks a golden.
- Injection-not-reliance: `TestRawProtobufConformance` (cms caps package)
drives representative capabilities with hand-constructed protobuf messages
through the `HostCallRequest` envelope — no guest SDK in the path.

View File

@ -1,154 +0,0 @@
# Codeless `.bnp` — Declarative Plugins (WO-WZ-020)
A **codeless `.bnp`** is a plugin artifact with **no `plugin.wasm`**: pure
declaration the host runs. No wazero compile, no instance pool, no capability
binding — zero guest code ever executes. This is the end-state of "injection,
not reliance" for themes, content sites, and template-only block packs: the
plugin is *data the host runs*, not a program that links a library. No Go, no
`block/core` dependency, no toolchain beyond the `ninja` CLI.
## The classifier — exactly one rule
`ninja plugin build` classifies by repo shape:
- **No Go source at the repo root → codeless.** The manifest is synthesized
from `plugin.mod` + the declarative files below; the artifact packs without
a wasm. `--codeless` asserts this and fails if Go is present.
- **Go source → wasm**, exactly as before. A converted repo DELETES its Go —
partial conversions keep a smaller wasm and still ship `blocks/` +
`seed/` alongside it (both artifact kinds carry the declarative dirs).
## The declarative / logic split
**Codeless-expressible** (declaration or host-rendered template):
- Blocks: a `.ninjatpl` template + declared **data providers** (the CMS's own
provider set — `posts`, `site`, `menus`, `authors`, … ADR 0018). The host
fetches, the engine renders. Defined in `blocks/blocks.yaml` — the SAME
manifest-FS layout core builtin block definitions use.
- Master pages, theme presets, fonts, CSS manifest, icon packs, settings
schema (`manifest.yaml`), assets (host-served), migrations (host-run Goose),
seed data (`seed/seed.json`, applied via the WO-WZ-019 provisioner).
- **Template overrides** (WO-WZ-021): theme-scoped re-renders of builtin
blocks. Declared in `manifest.yaml` as
`template_overrides: [{template: <system-key>, block: <block-key>}]`; the
source lives at `templates/overrides/<template>/<block>.ninjatpl` and is
rendered host-side with the block's content map as the template context.
- **Email wrappers** (WO-WZ-021): `email_wrappers: [<system-key>, …]` in
`manifest.yaml`, source at `templates/email/<system>.ninjatpl`. The context
carries `body` (pre-rendered HTML — emit it with `{{ body|safe }}`),
`colors.*` (snake_case hex tokens: `primary`, `primary_foreground`,
`secondary`, `secondary_foreground`, `background`, `foreground`, `muted`,
`muted_foreground`, `border`, `card`, `card_foreground`), `site.*` (`name`,
`logo_url`, `url`, `support_email`), `unsubscribe_url`, `preview_text`.
Render failure falls back to the unwrapped body.
**Requires code (a wasm plugin):**
- A block whose data no declared provider can produce; a *computing* tag or
filter; HTTP handlers; background jobs; Load/Unload logic; RAG fetchers;
media hooks; bridge services; AI tools; `data_dir`. A codeless manifest
declaring any of these is rejected at build (`ninja plugin verify` /
`CodelessHookViolation`) AND at load (cms reader `codelessHookViolation`).
## Repo layout
```
my-theme/
plugin.mod # name/version/kind (data_dir forbidden)
manifest.yaml # optional: theme_presets/bundled_fonts/settings_schema
# (JSON file refs), master_pages (JSON file),
# required_icon_packs, css {...}, dependencies [...],
# system_templates/page_templates,
# template_overrides [{template, block}],
# email_wrappers [system keys]
blocks/
blocks.yaml # key/title/category/schema/template/providers per block
hero.schema.json
hero.ninjatpl
templates/ # <system>/<key>.ninjatpl layout sources for the
# system/page templates declared in manifest.yaml,
# rendered host-side (blog/system/normal page layouts)
overrides/<system>/<block>.ninjatpl # template_overrides sources
email/<system>.ninjatpl # email_wrappers sources
seed/
seed.json # settings {merge/override/ensure}, media, pages, menu_items
hero.jpg # media bytes referenced by seed.json entries
assets/ # host-served statics
migrations/ # Goose SQL, host-run under the plugin schema
```
`ninja plugin build``<name>-<version>.bnp` (tar.zst, no `plugin.wasm`,
`manifest.pb` with `codeless: true`). `ninja plugin verify` re-runs the
loader's checks standalone.
## How the host runs it (cms `plugin/codeless_loader.go`)
- The `.bnp` reader accepts a missing `plugin.wasm` iff `manifest.codeless`,
and rejects codeless manifests with computing-hook declarations.
- Load: `blocks/``blocks.LoadManifest``Registry.RegisterDefinition`
rendering flows through the existing definition engine (providers +
ninjatpl + layer fallback, WO-089..096). `seed/seed.json` → provisioner
(`EnsureMedia`/`MergeSiteSettings`/`EnsureSetting`/`EnsurePage`/
`EnsureMenuItem`), idempotent by construction. Presets/fonts/CSS/master
pages/settings schema come from the manifest exactly as for wasm plugins.
- Unload/disable: definitions unregister; seeded content stays (it is site
data, not runtime state). Uninstall: normal teardown (schema/role drop,
artifact removal).
- Hot-swap: codeless→codeless swaps run migrations first, then re-register
definitions and re-apply seed. Wasm↔codeless transitions require
uninstall + reinstall.
## Mixed-form artifacts (WO-WZ-021)
A **mixed** `.bnp` keeps a `plugin.wasm` (it is NOT codeless) for genuine logic
— a contact route, a Stripe handler, a background job — while everything the
codeless surface can express stays declarative and **host-rendered**. This is
how the B2 site conversions (coterieos / coteriehealth / bcms-public /
judgefest) keep a minimal guest yet ship their page templates, overrides, email
wrappers, master pages, presets, fonts, and CSS as data the host runs.
The classifier is unchanged: a repo with Go source at the root builds wasm. What
changed is that a wasm build now **folds an optional root `manifest.yaml`** into
the DESCRIBE-derived `manifest.pb`, exactly as `BuildCodeless` does — the SAME
declarative key set: `theme_presets` / `bundled_fonts` / `settings_schema`
(JSON-file refs, embedded into `manifest.pb`), `master_pages` (JSON file),
`system_templates` / `page_templates`, `template_overrides`, `email_wrappers`,
`css`, `required_icon_packs`, `dependencies`. The `.ninjatpl` sources ride the
already-packed `templates/` dir; `blocks/` + `seed/` ride along as before.
- **Supplement / override, never wipe.** A declarative key supplements the
guest's DESCRIBE output (slice keys append; scalar/bytes keys overwrite) — but
a key the `manifest.yaml` does NOT declare leaves the guest's value untouched.
A repo with no `manifest.yaml` builds byte-for-byte as before.
- **Host-render precedence at load** (cms `plugin/wasm_loader.go`,
`registerWasmStatics`). A page template / block override / email wrapper is
host-rendered through the cms `pongoengine` (with the full synthesized page
context — `head_html` / `body_end_html` / `admin_banner_html`) **iff the
artifact ships its convention-path source** (`templates/<system>/<key>.ninjatpl`,
`templates/overrides/<template>/<block>.ninjatpl`,
`templates/email/<system>.ninjatpl`). Otherwise the guest forwarder stands. A
reduced plugin's DESCRIBE may still list a template it no longer renders
in-guest; the declarative source wins. Master pages / presets / fonts / CSS /
icon packs flow from `manifest.pb` through the existing wasm registration path
unchanged.
- **Lifecycle** rides the standard source-scoped `Register` /
`UnregisterBySource` pipeline, so install / disable / uninstall / hot-swap /
cross-form swap tear the declarative surfaces down with no extra wiring —
identical to codeless.
## Seed schema (`seed/seed.json`)
```json
{
"settings": {"merge": {"k": "v"}, "override": {"k": "v"}, "ensure": {"k": "v"}},
"media": [{"id": "<uuid>", "file": "hero.jpg", "alt": "", "folder": ""}],
"pages": [{"slug": "/", "title": "Home", "template_key": "landing",
"parent_slug": "", "reconcile_blocks": false,
"blocks": [{"block_key": "hero", "title": "Hero",
"content": {}, "slot": "main", "sort_order": 1}]}],
"menu_items": [{"menu": "main", "label": "Home", "page_slug": "/", "sort_order": 1}]
}
```
Media `id` is the deterministic, template-referable key (`media:<uuid>`,
`{% img %}`); bytes live in `seed/` next to the JSON. Apply order:
media → settings → pages → menu items.

View File

@ -1,602 +0,0 @@
# Wasm Plugin ABI (v1)
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
Design rationale: the wasm plugin migration design spec in the cms repo
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
The `abi/` buf module is deliberately separate from the repo-root buf config:
`proto/` is the shared block/proto git submodule (service API contracts),
while this ABI is SDK-internal and versions in lockstep with the guest shim,
so it lives repo-local.
## Versioning — `abi_version`
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
**major** version the plugin was built against. Current value: **1**.
- The host **rejects** any manifest whose major version it does not support —
at install/publish time (manifest read) and again at `DESCRIBE`
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
newer guest shim can refuse an older host symmetrically).
- Within a major version, evolution is protobuf-additive only: new fields,
new `Hook` values, new capability methods. Removing or renaming anything
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
in `abi/buf.yaml`) enforces this against the previous commit.
## Module lifecycle — REACTOR mode (`_initialize`, no `_start`)
Plugins compile as WASI **reactors** (Go ≥ 1.24 toolchain for
`go:wasmexport`; this repo's floor is higher — check `go.mod`):
```
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
```
with one boilerplate main file next to the untouched Registration:
```go
//go:build wasip1
package main
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest"
func init() { wasmguest.Serve(Registration) }
func main() {} // never called — reactor mode
```
A reactor module exports `_initialize` instead of `_start`. The host MUST
run `_initialize` exactly once per instance **before any `bn_invoke`**
(wazero: `ModuleConfig.WithStartFunctions("_initialize")`); it runs package
init funcs — hence `Serve`, which stores the registration and returns.
`main` exists only to satisfy the linker and is never called.
> Command mode (plain `go build`) does NOT work and must not be used
> (empirically verified, Go 1.26 + wazero v1.12.0, 2026-07-03): `_start`
> runs `main` synchronously, so a blocking `main` (`select{}`) trips the Go
> deadlock detector and traps, while a returning `main` exits and closes
> the module — either way the exports are never callable. The original
> blocking-main design was amended to reactor mode for this reason.
## Building & packing (`ninja plugin build`)
`ninja plugin build` turns a plugin repo into a `.bnp` artifact in one command —
no Docker/podman, just the local Go toolchain (**≥ 1.24**, enforced with a clear
error) plus wazero. It replaces the in-container `.so` compile and the old
`make build-so`. Steps:
1. Parse `plugin.mod` for `name`/`version` (both required).
2. `GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .`
(reactor mode).
3. Extract `manifest.pb`: instantiate `plugin.wasm` with wazero and drive one
`HOOK_DESCRIBE`. The host functions are **stubbed to fail** — DESCRIBE must
not need a live host, so a plugin that reaches a capability at
register/describe time (e.g. a `db.*` call from `Register`) gets an
actionable error **naming the offending method** instead of a hang.
4. Stamp `manifest.data_dir` from `plugin.mod` (see below).
5. Pack a `tar.zst`: `plugin.wasm`, `plugin.mod`, `manifest.pb`, plus
`migrations/`, `schemas/`, `assets/`, and `web/dist` (Module Federation
output, flattened under `web/`) when present.
```
ninja plugin build [--dir .] [-o <name>-<version>.bnp]
ninja plugin verify <file.bnp>
```
`ninja plugin verify` re-runs the CMS `.bnp` reader's checks standalone (layout,
required members, path-safety + size caps on extraction, `abi_version` support,
and manifest name == `plugin.mod` name) so CI and the registry can gate uploads.
These rules are a **deliberate duplication** of the reader
(`cms backend/plugin/bnp/reader.go`); core cannot import the CMS module, and
WO-WZ-010's integration suite keeps the two in lockstep.
### Makefile convention — `make build-wasm`
Plugin repos expose a `build-wasm` target (replacing `build-so`) that just calls
the CLI:
```make
.PHONY: build-wasm
build-wasm:
ninja plugin build
```
### `plugin.mod` `data_dir`
`plugin.mod` may set an optional first-class boolean:
```toml
[plugin]
name = "my-plugin"
version = "0.1.0"
data_dir = true # request a persistent per-plugin /data preopen at load
```
`data_dir` is a real field on the mod parser (not an arbitrary key) precisely so
the CLI's mod round-trip cannot silently drop it — `writeMod` reconstructs
`plugin.mod` from known struct fields only. `ninja plugin build` copies it into
`PluginManifest.data_dir`; OFF by default.
## Calling convention (ptr+len, packed u64)
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
protobuf bytes in guest linear memory. The guest exports (via
`go:wasmexport`):
| Export | Signature | Purpose |
|---|---|---|
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
Host functions (guest→host capability calls) live in wasm import module
**`blockninja`** and use the same shape in reverse. There is exactly ONE
generic import rather than one symbol per capability family:
```
(blockninja) host_call(ptr: u32, len: u32) → packed: u64
```
The guest passes `(ptr, len)` framing a serialized `HostCallRequest` (whose
`method` string — `"<family>.<snake_method>"`, including the `db.*` driver
methods — already selects the family); the host returns a packed `u64`
framing a `HostCallResponse` that it wrote into guest memory via `bn_alloc`.
The guest shim releases that buffer after decoding, so the host must not
reuse it. Per-family import symbols were considered and rejected (decision,
WO-WZ-002): they would add ~40 declarations on both sides for zero type
safety, since the payloads are opaque protobuf bytes either way.
Buffers MUST come from `bn_alloc` on both paths — the guest rejects a
`bn_invoke` request pointer it did not hand out (`ABI_ERROR_CODE_DECODE`),
and treats an unknown host-call response pointer the same way.
A `packed` value of `0` means the callee could not even produce an envelope
(allocation failure / trap); the caller treats it as
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
> A logically-empty response is **not** packed `0`. A successful hook whose
> response message has no set fields (e.g. `LoadResponse`/`UnloadResponse`)
> proto-marshals to zero bytes; the guest still frames it as `(ptr, 0)` with a
> real pointer so the host reads a valid empty envelope. `bn_invoke` never
> returns packed `0` for a successful call. (Fixed in WO-WZ-003: the earlier
> `len==0 → return 0` shortcut made every successful empty-response hook —
> notably `HOOK_LOAD` — look like an INTERNAL failure and discard the
> instance. The cms host in WO-WZ-006 must likewise not conflate a
> zero-length payload with a missing envelope.)
Instances are single-threaded: one `bn_invoke` at a time per instance;
concurrency comes from the per-plugin instance pool.
### Per-instance state: block pools & `HostServices`
Because concurrency is per-instance, guest state a render path depends on must be
established on **every** pooled instance, not just the one the host runs the
`HOOK_LOAD` hook on. `HOOK_LOAD` fires exactly once per plugin (on one acquired
instance); the deps-receiving entry points (`HTTPHandler`/`JobHandlers` init)
init lazily and only on instances that serve those hooks. A block render
(`HOOK_RENDER_BLOCK`) receives **no** services — only `ctx` + content — so a
DB-backed block cannot get its pool from Load.
The same rule governs the WO-WZ-019 callback hooks: AI tool handlers
(`ai.tools.register`) and bridge service values (`Bridge.RegisterService`)
are recorded per instance, and `HOOK_AI_TOOL_CALL` / `HOOK_BRIDGE_CALL` may
land on ANY pooled instance — so register them in `Register`, not `Load`.
The escape hatch is `wasmguest.HostServices()`: it returns the `CoreServices`
bound at `_initialize` on **every** instance (live `db.*` `Pool` + capability
stubs). A DB-backed block registers its pool from `HostServices().Pool` inside
`Register` (which `newGuest` runs on every instance, after the Pool is bound) —
see symposium's `register.go`. In native/DESCRIBE builds `HostServices()` returns
the zero value (Serve never ran), so callers nil-check `Pool`.
### Instance pool sizing & memory budget (WO-WZ-012)
Defaults (`wasmhost.DefaultConfig`, overridable via `WASM_POOL_MAX_SIZE` /
`WASM_MEMORY_LIMIT_MB`): **pool of 4 live instances per plugin, 512 MiB linear
memory cap per instance**, 30 s call deadline, 5 m idle TTL. The compiled module
(machine code + data segments) is compiled **once** per plugin and shared across
its pool; only each instance's linear memory + Go heap is per-instance.
Measured against the ported **symposium** plugin (45 MiB wasm — the fleet's
largest; representative public block mix = wiki index + course index + community
feed, one sqlc list query each over the `db.*` bridge, real wazero + Postgres):
| Metric | Value |
|---|---|
| Page render (3-block mix) p50 / p95 / p99 | **5.5 ms / 9.4 ms / 13.9 ms** |
| Per-block render (incl. DB round-trip) | ~1.83 ms |
| Process RSS, pool=1 (compile + 1 instance) | ~400500 MiB |
| Process RSS, pool=2 under concurrent load | ~540 MiB |
| Process RSS, pool=4 under concurrent load | ~628 MiB |
| Marginal cost per extra pooled instance | ~50130 MiB |
The ~400 MiB fixed cost is dominated by wazero compiling the 45 MiB module (once,
shared); marginal instances are cheap. Against the default orchestrator container
limit (`CONTAINER_MEMORY_MB` = **4096 MiB**), symposium at pool=4 (~628 MiB) plus
two smaller site-plugin pools + the CMS base fits comfortably. **Decision: keep
pool=4 / 512 MiB.** Memory-constrained deployments (≤1 GiB containers running the
largest plugins) should lower `WASM_POOL_MAX_SIZE` to 2.
Latency gate: the `.so` baseline for the identical block mix is unavailable on
`cms` main (the `.so` loader/builder was deleted in the big-bang cutover,
a04277ee2/da6177e1e), so a direct ≤25% p95 regression comparison cannot be run.
Absolute wasm numbers are recorded above; the wasm boundary overhead is small
relative to the per-block DB round-trip (~sub-ms of the ~3 ms), so a material
regression is not expected — recorded here for **explicit sign-off** per the
adjusted gate. Bench harness: `cms/backend/plugin/wasmintegration/symbench`.
## Hook catalog (`invoke.proto`)
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}`
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
| Hook | Request / Response | Fires |
|---|---|---|
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). The response carries **either** final `html` **or** a `powered` `{template, data_json}` result — see [Powered blocks](#powered-blocks--render-as-a-host-capability). |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
| `HOOK_RENDER_TAG` | `RenderTagRequest` / `RenderTagResponse` | Run a plugin-declared template tag (`manifest.declared_tags`) the host engine hit while rendering a powered block (`blocks.RegisterTag`). Re-entrancy-free: the originating RENDER_BLOCK has returned. |
| `HOOK_APPLY_FILTER` | `ApplyFilterRequest` / `ApplyFilterResponse` | Apply a plugin-declared template filter (`manifest.declared_filters`, `blocks.RegisterFilter`). |
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
| `HOOK_AI_TOOL_CALL` | `AiToolCallRequest` / `AiToolCallResponse` | Execute the guest handler of a tool registered via `ai.tools.register`. Register tools in `Register` (every pooled instance runs it), not `Load`. |
| `HOOK_BRIDGE_CALL` | `BridgeCallRequest` / `BridgeCallResponse` | Provider side of `bridge.invoke`: run a method on one of THIS plugin's registered bridge services (`plugin.BridgeInvokable`). Register services in `Register`. |
| `HOOK_DIRECTORY_PANEL_SECTION` | `DirectoryPanelSectionRequest` / `DirectoryPanelSectionResponse` | Render the index-th `DirectoryExtensions.PanelSections` callback. |
| `HOOK_DIRECTORY_PIN_DECORATOR` | `DirectoryPinDecoratorRequest` / `DirectoryPinDecoratorResponse` | Run the index-th pin decorator; the mutated pin map is returned. |
## Capability calls (`capability.proto`, `db.proto`)
Guest→host. Envelope: `HostCallRequest{method, payload}`
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
each pair mirrors one Go interface method from `CoreServices` 1:1
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
| Family | Methods | Go surface |
|---|---|---|
| `content` | `get_author_profile`, `get_page`, `get_post`, `list_posts`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` — plus the WO-WZ-019 writes: `create_page`, `set_page_blocks`, `publish_page`, `set_page_seo`, `upsert_post` | `content.Content`, `content.Author` |
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting`, `update_plugin_settings` (host-pinned to the caller's own plugin) | `settings.Settings`, `settings.Updater` |
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
| `media` | `deposit` | `plugin.Media` |
| `email` | `send` | `plugin.EmailSender` |
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
| `bridge` | `register_service`, `get_service`, `invoke` (opaque-payload cross-plugin calls; provider answers via `HOOK_BRIDGE_CALL` or an in-process `plugin.BridgeInvokable`) | `plugin.PluginBridge` |
| `jobs` | `submit`, `progress` (correlated to the running job via the HOOK_JOB call context) | `plugin.JobRunner` + `JobHandlerFunc`'s progress callback |
| `provisioner` | `ensure_data_table`, `merge_site_settings`, `ensure_setting`, `ensure_page`, `override_site_settings`, `ensure_menu_item`, `register_embedding_config`, `ensure_embed`, `ensure_job_schedule`, `update_data_table_row_field`, `disable_orphaned_job_schedules`, `ensure_plugin`, `ensure_custom_color`, `ensure_media`**Load-time**: call `deps.Provisioner` from `Load`; `RegisterWithProvisioner` is inert under wasm (DESCRIBE stubs host functions). `EnsureEmbed` rejects `RenderFunc`-only embeds (functions cannot cross). | `plugin.Provisioner` |
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
The guest half of the SDK implements the existing Go interfaces as stubs
marshaling to these calls (`core/plugin/wasmguest/caps/`, WO-WZ-003), so
plugin code compiles unchanged. `caps.NewCoreServices(call)` assembles them;
the wasm shim binds `call` to the real `host_call` transport, tests inject a
fake, and a nil transport (DESCRIBE probes) fails every capability cleanly
instead of nil-panicking.
### Method disposition (every `CoreServices` member)
No silent gaps: each member is either a guest stub or served host-side.
| Member | Disposition |
|---|---|
| `Content` (8 methods) | **stub**`caps/content.go` |
| `ContentAuthor` (5 write methods, WO-WZ-019) | **stub**`caps/author.go` |
| `Provisioner` (14 methods, WO-WZ-019) | **stub**`caps/provisioner.go`; Load-time only (see the `provisioner` family row) |
| `Settings` / `SettingsUpdater` | **stub**`caps/settings.go` (one value, both fields) |
| `Gating` | **stub**`caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
| `Crypto` | **stub**`caps/crypto.go` |
| `Menus` | **stub**`caps/menus.go` |
| `Datasources` | **stub**`caps/datasources.go` |
| `PublicUsers` | **stub**`caps/users.go` |
| `Subscriptions` | **stub**`caps/subscriptions.go` |
| `Media` | **stub**`caps/media.go` |
| `ToolRegistry` + `AITextCall` | **stub**`caps/ai.go` (`ai.tools.register` + `ai.text_call`); the tool `Handler` stays guest-side, recorded per instance and executed via `HOOK_AI_TOOL_CALL` — register tools in `Register`, not `Load` |
| `EmailSender` | **stub**`caps/email.go` |
| `Bridge` | **stub**`caps/bridge.go`; `RegisterService` forwards the name AND records the value locally for `HOOK_BRIDGE_CALL` dispatch; `GetService` reports availability but returns `nil` (a typed value cannot cross — by design); cross-plugin calls use `Invoke` (`bridge.invoke`, opaque payloads) |
| `ReviewSubmitter` | **stub**`caps/reviews.go` |
| `BadgeRefresher` | **stub**`caps/badges.go` |
| `JobRunner` | **stub**`caps/jobs.go` |
| `EmbeddingService` | **stub**`caps/embeddings.go` |
| `RAGService` | **stub**`caps/rag.go`; `Query`/`OnContentChanged` cross, `RegisterContentFetcher` records guest-side for `HOOK_RAG_FETCH` |
| `Pool` | **host-side** — the `db.*` driver (db.proto), per-plugin Postgres role |
| `Interceptors` | **host-side** — the host builds the connect option chain; RBAC merges from `manifest.rbac_method_roles`. The caller's **verified** identity reaches the guest via **trusted identity headers** (see "Trusted identity headers" below) — never by decoding a client token. |
| `AppURL` / `MediaPath` | **host-side** — delivered once in `LoadRequest.host_config` |
| `CoreServiceBindings` | **host-side** — static `manifest.core_service_bindings`; the host constructs and mounts the `http.Handler` (cannot cross the sandbox), so `caps` provides no stub |
Interface satisfaction is proven at compile time by a `var _ <iface> =
(*stub)(nil)` line per family; a wasip1 build of `testdata/fixture` (whose
`Load` hook calls `deps.Content`/`deps.Settings`/`deps.Bridge` unchanged) plus
the `TestWasmFixtureCapabilityRoundTrip` end-to-end wazero test prove the path
crosses the ABI for real.
Error mapping (`caps/caps.go`): a transport `AbiError` surfaces as a Go error
wrapped with `<family>.<method>` context; an `ABI_ERROR_CODE_DEADLINE_EXCEEDED`
reply is mapped onto `context.DeadlineExceeded` so `errors.Is` keeps working.
Methods without an error channel (`Slugify`, `IsAvailable`, `EvaluateAccess`,
`ToolRegistry.Register`, `Bridge.*`, `RAG.OnContentChanged`, …) degrade to the
zero value / best-effort on transport failure.
`CoreServices` members that do **not** cross as capability calls:
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
chain's deadline so a guest can never pin a connection. **Guest side
(WO-WZ-004):** `core/plugin/wasmguest/bnwasm` implements this over the
transport as two surfaces — a `database/sql` driver registered as `"bnwasm"`,
and a `plugin.Pool` handing out a `pgx.Tx`-shaped value. The latter is the
primary path: current plugins' sqlc configs use `sql_package: "pgx/v5"`, so
their generated `DBTX` needs `pgconn.CommandTag`/`pgx.Rows`/`pgx.Row` (which
`database/sql` cannot produce), and the `Pool`/`Tx` satisfy it with no source
edits. `DbError` surfaces as `*pgconn.PgError` (SQLSTATE preserved for
`errors.As`). Named args (`pgx.NamedArgs`/`QueryRewriter`) and nested
transactions/savepoints are rejected with clear errors — no fleet plugin uses
either. The DbValue↔Go scan mapping is pinned in the exported
`bnwasm.DbValueFixtures` table, which the WO-WZ-007 host executor mirrors.
**`text[]` NULL-element limit:** `DbValue.text_array` (`abiv1.TextArray`) is a
repeated string with no per-element NULL, so a Postgres `text[]` like
`{a,NULL,b}` cannot round-trip — a NULL element collapses to `""`. The array as
a whole can still be SQL NULL (nil `[]string``DbValue_Null`); only a NULL
*inside* the array is unrepresentable. The host executor must honor this same
limit (encode a NULL element as `""` or reject it), not invent a sentinel.
**`uuid[]` (`DbValue.uuid_array_value`, `abiv1.UuidArray`):** a first-class
variant distinct from `text[]`, so a query keeps native `ANY($1::uuid[])`
(no `::text[]::uuid[]` cast workaround) and a `uuid[]` column scans straight
into `[]uuid.UUID`. Each element is a canonical UUID string (like the scalar
`uuid_value`); nil `[]uuid.UUID``DbValue_Null`, empty stays a non-NULL
empty `uuid[]`. The host binds a native `[]uuid.UUID` parameter and reads a
`UUIDArrayOID` column back into this variant. Pinned by the `uuid_array`
entry in `bnwasm.DbValueFixtures`.
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
`manifest.rbac_method_roles`.
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
declaration; the host constructs and mounts the handlers.
- `RAGService.RegisterContentFetcher` → static
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
callback inversion.
## Trusted identity headers
Context values do **not** cross the ABI, so a guest cannot see the
`auth.Claims` / `auth.PublicClaims` the host's middleware built. A guest that
needs the caller's identity (its Connect RPCs call
`auth.GetUserFromContext` / `GetPublicUserFromContext`) reads it from
**host-set trusted headers**, and **only** from those.
**Trust model (host-enforced, guest-trusting):**
1. Upstream CMS auth middleware verifies the JWT signature and populates
`r.Context()` with the principal. The wasm mount's RBAC guard then runs
`rbac.Authorize` against that **verified** principal (deny-by-default,
live-user validator) *before* the request is forwarded.
2. When forwarding over `HOOK_HANDLE_HTTP`, the host **strips any
client-supplied copy** of the trusted headers from the request and **sets
them itself** from the verified context principal. A guest therefore trusts
them unconditionally — they are not attacker-controllable.
3. The guest reconstructs its context with `auth.TrustedHeaderMiddleware`
(wrap it around your `HTTPHandler`) or `auth.ContextFromTrustedHeaders`.
No token parsing, no signature check — the guest holds no signing secret by
design.
The headers (`core/auth/trustedheaders.go`, canonical MIME form):
| Header | Source |
|---|---|
| `X-Bn-Verified-User-Id` / `X-Bn-Verified-Role` / `X-Bn-Verified-Email` | admin `auth.Claims` |
| `X-Bn-Verified-Public-User-Id` / `X-Bn-Verified-Public-Username` / `X-Bn-Verified-Public-Email` | public `auth.PublicClaims` |
> **SECURITY — do NOT decode a client token in the guest.** The host forwards
> the raw request, so its `Cookie` / `Authorization` headers are
> attacker-controlled across the boundary. Decoding an unsigned `access_token`
> cookie (or Bearer JWT) as identity in the guest is a **privilege-escalation
> bug** (a verified public user can forge an admin JWT the guest would then
> honour on a `RolePublic` method). Identity comes from the trusted headers
> above and nowhere else.
## Error semantics
`AbiError{code, message}` travels in `InvokeResponse.error` and
`HostCallResponse.error`:
| Code | Meaning | Instance consequence |
|---|---|---|
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
| `ABI_ERROR_CODE_TX_EXPIRED` | A `db.*` call named a transaction handle the host already expired (dropped at the call-chain deadline, WO-WZ-007). | None — retryable. The guest maps it to `bnwasm.ErrTxExpired`; plugin code re-runs the unit of work in a fresh transaction. |
`ABI_ERROR_CODE_TX_EXPIRED` is emitted by the cms `dbexec` side (adopted
separately from this WO) in place of the earlier INTERNAL+message-marker
workaround, so guests can distinguish a retryable tx expiry from a real fault
via `errors.Is(err, bnwasm.ErrTxExpired)`.
Host-function errors surface to plugin code as ordinary Go errors via the
guest SDK. Repeated instance failures trip the existing
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
sqlc/pgx error handling keeps working.
## Manifest ↔ `PluginRegistration` mapping
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
Field-by-field:
| `PluginRegistration` field | Wire counterpart |
|---|---|
| `Name` | `PluginManifest.name` |
| `Version` | `PluginManifest.version` |
| `Dependencies` | `dependencies` (`Dependency`) |
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys`, `declared_tags`, `declared_filters` |
| `blocks.RegisterTag` / `blocks.RegisterFilter` (called in `Register`) | `declared_tags` / `declared_filters` + `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` |
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
| `SettingsSchema` | `settings_schema` (JSON bytes) |
| `ThemePresets` | `theme_presets` (JSON bytes) |
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
| `SettingsPanel` | `settings_panel` |
| `AdminPages` | `admin_pages` (`AdminPage`) |
| `CSSManifest` | `css_manifest` (`CssManifest`) |
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
| `JobHandlers` | `job_types` + `HOOK_JOB` |
| `AIActions` | `ai_actions` (`AiAction`) |
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
| `Load` | `has_load_hook` + `HOOK_LOAD` |
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
| `RequiredIconPacks` | `required_icon_packs` |
| *(none — `plugin.mod` `data_dir`)* | `data_dir` (bool). Not a `PluginRegistration` field: the grant lives in `plugin.mod`, which the guest code cannot see, so `ninja plugin build` stamps `PluginManifest.data_dir` from `plugin.mod` after DESCRIBE. The `.bnp` reader also reads it straight from `plugin.mod` as a fallback. |
## Render context
`RenderContext` (`render.proto`) is the explicit envelope of every value
blocks read from `ctx` today (`core/blocks/context.go`): request info,
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
category/master-page, requested path, injected/expected slots, editor flag,
block + page IDs, human-proof banner, detail row, theme variables JSON, and
locale.
Function-valued context entries cannot serialize; their v1 mapping:
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
- `SlotRenderer` / `MediaResolver` / `EmbedResolver`**open items** (below).
## Powered blocks — render as a host capability
pongo2/ninjatpl is a **host capability**: the template engine stays host-side
(cms) and is **never** compiled into a guest. A guest-reachable core package
(anything under `blocks/` or `plugin/wasmguest/`) MUST NOT import
`github.com/flosch/pongo2/*` — verified by `go list -deps` on the compiled
guest plugin showing zero `flosch/pongo2`. `core/templates/pongo` (the engine)
is core-resident but host-only; it is not in any guest's dependency graph.
Because the guest can't render a template itself, a template-backed block
**defers** rendering to the host. A `blocks.BlockFunc` returns EITHER:
- **plain HTML** — a non-template block returns its final string as always; or
- **a powered result**`blocks.PoweredBlock(template, data)`: the block
builds its data (via capabilities like `Content.ListPosts`) and hands the
host a `{template, data}` pair instead of final HTML.
`PoweredBlock` encodes the `{template, data}` behind a NUL-delimited sentinel
in the `BlockFunc`'s `string` return, so **`BlockFunc`'s signature is
unchanged** (the smallest additive change — no ripple to existing blocks or the
host guest-side). The guest's RENDER_BLOCK handler decodes the sentinel
(`blocks.DecodePoweredBlock`) and fills `RenderBlockResponse.powered`
(`PoweredBlock{template, data_json}`); a plain HTML block fills
`RenderBlockResponse.html` and leaves `powered` nil.
### Flow (re-entrancy-free by construction)
1. Host calls guest `HOOK_RENDER_BLOCK`. A template-backed block returns a
**powered** result `{template, data_json}`; a plain block returns `html`.
2. **After the block-invoke returns**, the host renders the powered `template`
with `data_json` using pongo2, host-side. The guest instance is now free.
3. When the host engine hits a plugin-declared tag (`{% mytag %}`) or filter
(`{{ x|myfilter }}`), it calls back into the *free* guest instance:
`HOOK_RENDER_TAG {tag_name, args_json, render_context} → {html, error}` or
`HOOK_APPLY_FILTER {filter_name, input, args_json} → {output, error}`. Each
callback is a **fresh** `bn_invoke` — never nested inside the still-running
RENDER_BLOCK — so there is no guest re-entrancy.
### Plugin API (`core/blocks`)
Registered in the plugin's `Register` func (alongside blocks), guest-safe (no
pongo2):
```go
// A block returns a powered result instead of final HTML:
func MyBlock(ctx context.Context, content map[string]any) string {
posts := loadPosts(ctx) // via deps/HostServices capabilities
return blocks.PoweredBlock("{% for p in posts %}<li>{{ p.title }}</li>{% endfor %}",
map[string]any{"posts": posts})
}
// Custom tag: args are the parsed tag arguments; rctx is the render context
// (blocks.RenderContext == context.Context — read state via blocks.Get*).
blocks.RegisterTag("mytag", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
return "<span></span>", nil
})
// Custom filter: input is the piped value; args are the filter arguments.
blocks.RegisterFilter("myfilter", func(input string, args map[string]any) (string, error) {
return strings.ToUpper(input), nil
})
```
`RegisterTag`/`RegisterFilter` write a package-level registry (one plugin owns
a wasm module). The guest resets it at the start of each registration's
`Register` pass (`runRegister`), so DESCRIBE captures exactly this plugin's
names into `declared_tags`/`declared_filters` and HOOK dispatch resolves them
via `blocks.LookupTag`/`LookupFilter`. Register tags/filters **in `Register`**
(not package `init`), so DESCRIBE — which runs `Register` — sees them.
A tag/filter fn error surfaces in `RenderTagResponse.error` /
`ApplyFilterResponse.error` (a normal logic failure the host decides how to
render). `AbiError` stays reserved for transport/decode/panic failures; a panic
in a tag/filter recovers to `ABI_ERROR_CODE_INTERNAL` and the instance stays
callable, matching every other hook.
### Host-side contract (the cms phase implements)
The core half (this SDK) defines the wire + guest dispatch. The cms host must:
1. **Render powered blocks host-side.** After `HOOK_RENDER_BLOCK` returns, if
`RenderBlockResponse.powered` is set, render `powered.template` with
`powered.data_json` (JSON → `map[string]any`) through the existing pongo2
engine (`core/templates/pongo`) and use that as the block's HTML. If `html`
is set instead, use it directly. Do the pongo2 render **outside** the
guest-invoke call so the instance is free for callbacks.
2. **Wire per-plugin callback tags/filters.** At load, read
`manifest.declared_tags` / `manifest.declared_filters` and register, in the
pongo2 environment used to render that plugin's powered blocks, one tag per
declared name that invokes `HOOK_RENDER_TAG` (packing the tag args +
current `RenderContext`) and one filter per declared name that invokes
`HOOK_APPLY_FILTER`. Surface a non-empty response `error` as a render error.
3. **Rely on the re-entrancy guarantee.** Because step 1 renders only after the
block-invoke returned, the callbacks in step 2 are fresh invokes on a free
instance — no special re-entrancy handling is needed.
> Migration note: existing blocks that call `blocks.RenderTemplate` inside the
> block (host-side .so world) switch to returning `blocks.PoweredBlock` in the
> wasm world. This SDK ships the mechanism only; per-plugin migration (e.g.
> assumechaos) is a later phase.
## Open items
WO-WZ-019 closed the original set: AI tool execution (`HOOK_AI_TOOL_CALL`),
job progress (`jobs.progress`), cross-plugin bridge invocation
(`bridge.invoke` + `HOOK_BRIDGE_CALL` + `plugin.BridgeInvokable`), directory
extension callbacks (`HOOK_DIRECTORY_PANEL_SECTION` /
`HOOK_DIRECTORY_PIN_DECORATOR`), plugin provisioning (the `provisioner.*`
family), content authoring (`content.*` writes), and the plugin-own settings
write. The full capability→mechanism map lives in
[abi-capability-surface.md](abi-capability-surface.md).
Still open:
- **Slot/media/embed resolvers in render**: container-slot rendering and
media/embed resolution during a guest render need host functions (or host-
side pre-rendering into `RenderContext`). The powered-block path sidesteps
this — host-side rendering has the real resolvers.

12
go.mod
View File

@ -6,30 +6,18 @@ require (
connectrpc.com/connect v1.20.0
github.com/BurntSushi/toml v1.6.0
github.com/a-h/templ v0.3.1020
github.com/chromedp/chromedp v0.15.1
github.com/flosch/pongo2/v6 v6.1.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/klauspost/compress v1.18.6
github.com/spf13/cobra v1.10.2
github.com/tetratelabs/wazero v1.12.0
golang.org/x/mod v0.34.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

30
go.sum
View File

@ -4,32 +4,15 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/flosch/pongo2/v6 v6.1.0 h1:A/NJbrQJJD2B2mbpw3DRFwBYG0xpCr3vwFlEr46y1HQ=
github.com/flosch/pongo2/v6 v6.1.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -38,25 +21,14 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@ -64,12 +36,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=

File diff suppressed because it is too large Load Diff