The ninja developer CLI moves out of block/core into its own module git.dev.alexdunmow.com/block/cli. Pins block/core@v0.18.2 for abi/v1, the plugin.mod parser, and DESCRIBE guest builds; vendors its own orchestrator registry client (internal/api, generated from a vendored plugin_registry.proto) since it cannot import block/core/internal. Produces byte-identical v1 artifacts (verified: art-deco codeless SHA256-identical to the pre-move build). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
8.6 KiB
Go
280 lines
8.6 KiB
Go
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
|
|
}
|