cli/cmd/ninja/cmd/abi.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

138 lines
3.9 KiB
Go

package cmd
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"git.dev.alexdunmow.com/block/cli/internal/creds"
"github.com/spf13/cobra"
)
// newAbiCmd is the `ninja abi` group: fetch the versioned wasm-plugin ABI
// contract (proto schema + calling-convention docs) the registry publishes, so
// any language can codegen against it (WO-WZ-023).
func newAbiCmd() *cobra.Command {
c := &cobra.Command{
Use: "abi",
Short: "Work with the wasm-plugin ABI contract",
Long: "Fetch the versioned ABI contract (proto schema + docs) published by the registry.",
}
c.AddCommand(newAbiPullCmd())
return c
}
func newAbiPullCmd() *cobra.Command {
var version string
var outDir string
cmd := &cobra.Command{
Use: "pull",
Short: "Download and extract the ABI contract tarball from the registry",
Long: "Fetches <host>/registry/abi/<version>/contract.tar.gz (public, no login\n" +
"required) and extracts the proto schema + docs into a directory.",
RunE: func(c *cobra.Command, _ []string) error {
hostFlag, _ := c.Flags().GetString("host")
host := resolveContractHost(hostFlag)
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
url := strings.TrimRight(host, "/") + "/registry/abi/" + version + "/contract.tar.gz"
req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("fetch contract: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("registry returned %s for %s: %s", resp.Status, url, strings.TrimSpace(string(body)))
}
if outDir == "" {
outDir = filepath.Join("abi-contract", version)
}
n, err := extractContract(resp.Body, outDir)
if err != nil {
return err
}
fmt.Printf("Pulled ABI contract %s → %s (%d files)\n", version, outDir, n)
return nil
},
}
cmd.Flags().StringVar(&version, "version", "v1", "ABI contract version to pull (e.g. v1)")
cmd.Flags().StringVarP(&outDir, "output", "o", "", "output directory (default: abi-contract/<version>)")
return cmd
}
// resolveContractHost resolves the registry host for a public (unauthenticated)
// download: the --host flag wins, else the logged-in default host, else the
// production registry. It never errors on a missing token — the contract route
// is public.
func resolveContractHost(hostFlag string) string {
if hostFlag != "" {
return hostFlag
}
if cr, err := creds.Load(); err == nil && cr.DefaultHost != "" {
return cr.DefaultHost
}
return "https://my.blockninjacms.com"
}
// extractContract untars a gzip stream into dir, refusing any entry that would
// escape the destination root. Returns the number of files written.
func extractContract(r io.Reader, dir string) (int, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return 0, err
}
gz, err := gzip.NewReader(r)
if err != nil {
return 0, fmt.Errorf("open gzip: %w", err)
}
defer func() { _ = gz.Close() }()
tr := tar.NewReader(gz)
var count int
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return count, fmt.Errorf("read tar: %w", err)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
clean := filepath.Clean(hdr.Name)
if !filepath.IsLocal(clean) {
return count, fmt.Errorf("refusing unsafe tar path %q", hdr.Name)
}
dest := filepath.Join(dir, clean)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return count, err
}
f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return count, err
}
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // size-bounded contract tarball from our own registry
_ = f.Close()
return count, err
}
if err := f.Close(); err != nil {
return count, err
}
count++
}
return count, nil
}