cli/cmd/ninja/cmd/plugin_build.go
Alex Dunmow e8aba45f21 feat(bnp): pack preview.png + screenshots/ into artifacts; multi-page capture
The .bnp packers never carried preview.png (the .so-era source archives did
implicitly), so any republish silently wiped a theme's registry preview.
Both builders now pack an optional root preview.png and a screenshots/ dir
(png/jpg/jpeg/webp, <=8MB each, <=12 files, validated), reported in the
build/verify summaries. ninja theme screenshot gains --pages/--screenshots-dir
to capture NN-<slug>.png per gallery page for the registry gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 08:21:35 +08:00

143 lines
4.9 KiB
Go

package cmd
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"git.dev.alexdunmow.com/block/cli/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(" │ preview.png %t\n", r.PreviewIncluded)
fmt.Printf(" │ screenshots %d\n", r.ScreenshotCount)
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] preview=%t screenshots=%d\n",
r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined, r.HasPreview, r.ScreenshotCount)
}
// 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])
}