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:
parent
3ce6f9f4a0
commit
c7cbf69f63
@ -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.
|
||||
|
||||
15
Makefile
15
Makefile
@ -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
|
||||
|
||||
10
README.md
10
README.md
@ -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"
|
||||
```
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
@ -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])
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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, ", ")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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 ""
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
@ -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.
|
||||
602
docs/wasm-abi.md
602
docs/wasm-abi.md
@ -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.8–3 ms |
|
||||
| Process RSS, pool=1 (compile + 1 instance) | ~400–500 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 | ~50–130 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
12
go.mod
@ -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
30
go.sum
@ -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
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user