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>
138 lines
3.9 KiB
Go
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
|
|
}
|