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 → "-.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 }