cli/internal/bnp/build.go
Alex Dunmow 2f065e3ed4 feat: ninja CLI in its own repo (block/cli, WO-WZ-023)
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>
2026-07-04 22:39:32 +08:00

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
}