From c7cbf69f632048cdd78da5fb3aeaac06505a6bc2 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sat, 4 Jul 2026 22:37:24 +0800 Subject: [PATCH] refactor(abi): shed ABI contract + ninja CLI (WO-WZ-023) The wasm-plugin ABI contract now lives in the cms (block/cms/abi/v1 + docs) and the ninja CLI moved to its own repo (block/cli). Core keeps abi/ as the guest SDK until WO-WZ-027. Removes cmd/ninja, the dead orchestrator registry client (internal/api/orchestrator), the moved docs, and the now-unused ninja/orchclient deps from go.mod/go.sum. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 4 +- Makefile | 15 +- README.md | 10 +- cmd/ninja/cmd/account.go | 150 - cmd/ninja/cmd/login.go | 150 - cmd/ninja/cmd/plugin.go | 1238 ----- cmd/ninja/cmd/plugin_build.go | 140 - cmd/ninja/cmd/plugin_build_test.go | 279 -- cmd/ninja/cmd/plugin_tags.go | 175 - cmd/ninja/cmd/plugin_test.go | 637 --- cmd/ninja/cmd/root.go | 21 - cmd/ninja/cmd/scope.go | 230 - cmd/ninja/cmd/theme.go | 108 - cmd/ninja/cmd/theme_test.go | 36 - cmd/ninja/cmd/version.go | 19 - cmd/ninja/internal/archive/archive.go | 56 - cmd/ninja/internal/archive/archive_test.go | 208 - cmd/ninja/internal/bnp/build.go | 279 -- cmd/ninja/internal/bnp/codeless.go | 515 -- cmd/ninja/internal/bnp/codeless_test.go | 183 - cmd/ninja/internal/bnp/manifest.go | 171 - cmd/ninja/internal/bnp/mixed_test.go | 79 - cmd/ninja/internal/bnp/pack.go | 138 - cmd/ninja/internal/bnp/verify.go | 329 -- cmd/ninja/internal/creds/creds.go | 88 - cmd/ninja/internal/creds/creds_test.go | 45 - cmd/ninja/internal/orchclient/client.go | 42 - cmd/ninja/internal/shot/shot.go | 87 - cmd/ninja/internal/shot/shot_test.go | 27 - cmd/ninja/main.go | 15 - docs/abi-capability-surface.md | 99 - docs/codeless-bnp.md | 154 - docs/wasm-abi.md | 602 --- go.mod | 12 - go.sum | 30 - .../plugin_registry.connect.go | 1098 ----- .../api/orchestrator/v1/plugin_registry.pb.go | 4295 ----------------- 37 files changed, 13 insertions(+), 11751 deletions(-) delete mode 100644 cmd/ninja/cmd/account.go delete mode 100644 cmd/ninja/cmd/login.go delete mode 100644 cmd/ninja/cmd/plugin.go delete mode 100644 cmd/ninja/cmd/plugin_build.go delete mode 100644 cmd/ninja/cmd/plugin_build_test.go delete mode 100644 cmd/ninja/cmd/plugin_tags.go delete mode 100644 cmd/ninja/cmd/plugin_test.go delete mode 100644 cmd/ninja/cmd/root.go delete mode 100644 cmd/ninja/cmd/scope.go delete mode 100644 cmd/ninja/cmd/theme.go delete mode 100644 cmd/ninja/cmd/theme_test.go delete mode 100644 cmd/ninja/cmd/version.go delete mode 100644 cmd/ninja/internal/archive/archive.go delete mode 100644 cmd/ninja/internal/archive/archive_test.go delete mode 100644 cmd/ninja/internal/bnp/build.go delete mode 100644 cmd/ninja/internal/bnp/codeless.go delete mode 100644 cmd/ninja/internal/bnp/codeless_test.go delete mode 100644 cmd/ninja/internal/bnp/manifest.go delete mode 100644 cmd/ninja/internal/bnp/mixed_test.go delete mode 100644 cmd/ninja/internal/bnp/pack.go delete mode 100644 cmd/ninja/internal/bnp/verify.go delete mode 100644 cmd/ninja/internal/creds/creds.go delete mode 100644 cmd/ninja/internal/creds/creds_test.go delete mode 100644 cmd/ninja/internal/orchclient/client.go delete mode 100644 cmd/ninja/internal/shot/shot.go delete mode 100644 cmd/ninja/internal/shot/shot_test.go delete mode 100644 cmd/ninja/main.go delete mode 100644 docs/abi-capability-surface.md delete mode 100644 docs/codeless-bnp.md delete mode 100644 docs/wasm-abi.md delete mode 100644 internal/api/orchestrator/v1/orchestratorv1connect/plugin_registry.connect.go delete mode 100644 internal/api/orchestrator/v1/plugin_registry.pb.go diff --git a/AGENTS.md b/AGENTS.md index ada19f1..762b254 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # Core SDK -Go module `git.dev.alexdunmow.com/block/core`. Defines plugin interfaces, template engine, block registry, and shared types for the BlockNinja CMS. +Go module `git.dev.alexdunmow.com/block/core`. Shared Go code between the **CMS and the orchestrator** — template engine, block registry, shared types, proto definitions. ## Critical Rules +- **Core is NOT for plugins.** Its only consumers are the CMS and the orchestrator; it exists purely to share code between those two. Plugins must not import `block/core` — in the wasm-plugin era they are standalone artifacts built against the wasm ABI, not this module. (Core was previously the plugin SDK; that role is gone.) - **NEVER use `replace` directives in go.mod** — not in this repo, not in any consumer. All module resolution goes through the Gitea module proxy. If you need to test local changes, tag and push a version. -- Plugins import from core only — never from the CMS (`blockninja/backend`) or orchestrator. - All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers. diff --git a/Makefile b/Makefile index 536d243..990b66d 100644 --- a/Makefile +++ b/Makefile @@ -8,17 +8,10 @@ SDK_DOWNSTREAM_DIRS := \ $(wildcard $(HOME)/src/blockninja/sites/*) \ $(wildcard $(HOME)/src/blockninja/plugins/*) -.PHONY: install-ninja -install-ninja: - go install ./cmd/ninja - -# Regenerate Go bindings from the proto/ submodule. We narrow to -# plugin_registry.proto because other orchestrator/v1 protos (accounts.proto -# etc.) are owned by the orchestrator's generated package; registering them -# from core too would panic at startup with "file ... is already registered". -.PHONY: proto -proto: - buf generate --path proto/orchestrator/v1/plugin_registry.proto +# The ninja CLI moved to its own repo (git.dev.alexdunmow.com/block/cli, +# WO-WZ-023, Phase 7 core dissolution). Its plugin-registry orchestrator client +# is generated there now, so core no longer carries an `install-ninja` or +# `proto` (plugin_registry) target. # Lint + regenerate Go bindings for the wasm plugin ABI (repo-local buf # module under abi/ — deliberately not part of the proto/ submodule; see diff --git a/README.md b/README.md index e2c0b68..6890241 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# BlockNinja Plugin SDK +# BlockNinja Core -Types, interfaces, and utilities for building BlockNinja plugins. +Shared Go code between the BlockNinja CMS and the orchestrator: types, interfaces, and utilities both need. + +> **Not a plugin SDK.** Core is purely for sharing code between the CMS and the orchestrator. Plugins must **not** import `block/core` — in the wasm-plugin era they are standalone artifacts built against the wasm ABI. (Core previously served as the plugin SDK; that role is gone.) ## Package Structure @@ -23,6 +25,8 @@ Types, interfaces, and utilities for building BlockNinja plugins. ## Usage +Consumers are the CMS and the orchestrator only. + ```go -import "git.dev.alexdunmow.com/ninja/core/plugin" +import "git.dev.alexdunmow.com/block/core/plugin" ``` diff --git a/cmd/ninja/cmd/account.go b/cmd/ninja/cmd/account.go deleted file mode 100644 index d901d05..0000000 --- a/cmd/ninja/cmd/account.go +++ /dev/null @@ -1,150 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "strconv" - "strings" - - "connectrpc.com/connect" - "github.com/spf13/cobra" - - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient" - v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1" -) - -func newAccountCmd() *cobra.Command { - c := &cobra.Command{ - Use: "account", - Short: "Manage which account ninja acts as", - Long: `Account-scoped commands like ` + "`ninja plugins publish --private`" + ` act -against an "active account" — the orchestrator-side account whose members -can see and install the plugin. The active account is selected at -` + "`ninja login`" + ` time and persisted in your credentials file.`, - } - c.AddCommand(newAccountListCmd(), newAccountSetCmd(), newAccountShowCmd()) - return c -} - -func newAccountListCmd() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List the accounts the authenticated user belongs to", - RunE: func(c *cobra.Command, _ []string) error { - cli, _, _, hc, err := resolveClient(c) - if err != nil { - return err - } - accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(), - connect.NewRequest(&v1.ListMyAccountsForCLIRequest{})) - if err != nil { - return fmt.Errorf("list accounts: %w", err) - } - if len(accts.Msg.Accounts) == 0 { - fmt.Println("No accounts.") - return nil - } - for _, a := range accts.Msg.Accounts { - marker := " " - if a.Id == hc.ActiveAccountID { - marker = "* " - } - fmt.Printf("%s%s — %s\n", marker, a.Slug, a.Name) - } - return nil - }, - } -} - -func newAccountSetCmd() *cobra.Command { - return &cobra.Command{ - Use: "set ", - Short: "Change the active account", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - cli, cr, host, hc, err := resolveClient(c) - if err != nil { - return err - } - slug := strings.TrimPrefix(args[0], "@") - accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(), - connect.NewRequest(&v1.ListMyAccountsForCLIRequest{})) - if err != nil { - return fmt.Errorf("list accounts: %w", err) - } - for _, a := range accts.Msg.Accounts { - if a.Slug == slug { - hc.ActiveAccountID = a.Id - hc.ActiveAccountSlug = a.Slug - cr.Hosts[host] = hc - if err := cr.Save(); err != nil { - return err - } - fmt.Printf("Active account: %s (%s)\n", a.Slug, a.Name) - return nil - } - } - return fmt.Errorf("account %q not found among your memberships; try `ninja account list`", slug) - }, - } -} - -func newAccountShowCmd() *cobra.Command { - return &cobra.Command{ - Use: "show", - Short: "Show the currently active account", - RunE: func(c *cobra.Command, _ []string) error { - _, _, _, hc, err := resolveClient(c) - if err != nil { - return err - } - if hc.ActiveAccountSlug == "" { - fmt.Println("(no active account set; run `ninja login` or `ninja account set `)") - return nil - } - fmt.Printf("Active account: %s (id=%s)\n", hc.ActiveAccountSlug, hc.ActiveAccountID) - return nil - }, - } -} - -// resolveClient is a small helper used by every `ninja account` subcommand: -// it loads creds, resolves the host, and returns an authed client plus the -// loaded credentials so the caller can persist changes. -func resolveClient(c *cobra.Command) (*orchclient.Client, *creds.Credentials, string, creds.HostCreds, error) { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return nil, nil, "", creds.HostCreds{}, err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return nil, nil, "", creds.HostCreds{}, err - } - return orchclient.New(resolvedHost, hc.Token), cr, resolvedHost, hc, nil -} - -// pickAccountInteractive prompts the user to select an account by number from -// the given list and returns the chosen account. Used by `ninja login` when -// the user belongs to more than one account. -func pickAccountInteractive(scanner *bufio.Scanner, accounts []*v1.MyAccount) (*v1.MyAccount, error) { - if len(accounts) == 0 { - return nil, fmt.Errorf("no accounts available") - } - fmt.Println("Select an account:") - for i, a := range accounts { - fmt.Printf(" %d) %s — %s\n", i+1, a.Slug, a.Name) - } - fmt.Print("> ") - if !scanner.Scan() { - return nil, fmt.Errorf("cancelled") - } - v := strings.TrimSpace(scanner.Text()) - n, err := strconv.Atoi(v) - if err != nil || n < 1 || n > len(accounts) { - return nil, fmt.Errorf("invalid selection: %s", v) - } - return accounts[n-1], nil -} diff --git a/cmd/ninja/cmd/login.go b/cmd/ninja/cmd/login.go deleted file mode 100644 index b31f046..0000000 --- a/cmd/ninja/cmd/login.go +++ /dev/null @@ -1,150 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "os" - "time" - - "connectrpc.com/connect" - "github.com/spf13/cobra" - - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient" - v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1" -) - -func newLoginCmd() *cobra.Command { - var host string - cmd := &cobra.Command{ - Use: "login", - Short: "Authenticate against the orchestrator using device flow", - RunE: func(c *cobra.Command, _ []string) error { - if host == "" { - host, _ = c.Flags().GetString("host") - } - if host == "" { - host = "https://my.blockninjacms.com" - } - cli := orchclient.New(host, "") - ctx := context.Background() - start, err := cli.Auth.StartDevice(ctx, connect.NewRequest(&v1.StartDeviceRequest{ - Scopes: []string{"plugin:read", "plugin:publish", "scope:admin"}, - })) - if err != nil { - return fmt.Errorf("start device: %w", err) - } - fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode) - interval := time.Duration(start.Msg.IntervalSeconds) * time.Second - deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second) - for time.Now().Before(deadline) { - time.Sleep(interval) - poll, err := cli.Auth.PollDevice(ctx, connect.NewRequest(&v1.PollDeviceRequest{DeviceCode: start.Msg.DeviceCode})) - if err != nil { - return err - } - switch poll.Msg.Status { - case "pending": - continue - case "approved": - cr, err := creds.Load() - if err != nil { - return err - } - cr.DefaultHost = host - if cr.Hosts == nil { - cr.Hosts = map[string]creds.HostCreds{} - } - hc := creds.HostCreds{Token: poll.Msg.AccessToken} - authed := orchclient.New(host, hc.Token) - if err := selectActiveAccount(ctx, authed, &hc); err != nil { - return err - } - cr.Hosts[host] = hc - if err := cr.Save(); err != nil { - return err - } - fmt.Println("Logged in.") - return nil - case "expired": - return fmt.Errorf("device code expired; try again") - } - } - return fmt.Errorf("login timed out") - }, - } - cmd.Flags().StringVar(&host, "host", "", "Orchestrator base URL") - return cmd -} - -func newWhoamiCmd() *cobra.Command { - return &cobra.Command{ - Use: "whoami", - Short: "Show the currently logged-in user", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - r, err := cli.Auth.Whoami(context.Background(), connect.NewRequest(&v1.WhoamiRequest{})) - if err != nil { - return err - } - fmt.Printf("%s <%s> at %s\n", r.Msg.DisplayName, r.Msg.Email, resolvedHost) - return nil - }, - } -} - -// selectActiveAccount fetches the user's accounts and writes the active one -// into hc. With 0 accounts it errors (the server contract guarantees every -// user has at least one). With 1 it auto-selects silently. With ≥2 it -// prompts interactively on stdin. -func selectActiveAccount(ctx context.Context, cli *orchclient.Client, hc *creds.HostCreds) error { - resp, err := cli.Auth.ListMyAccountsForCLI(ctx, connect.NewRequest(&v1.ListMyAccountsForCLIRequest{})) - if err != nil { - return fmt.Errorf("list accounts: %w", err) - } - accts := resp.Msg.Accounts - switch len(accts) { - case 0: - return fmt.Errorf("no accounts found for this user; contact support") - case 1: - hc.ActiveAccountID = accts[0].Id - hc.ActiveAccountSlug = accts[0].Slug - fmt.Printf("Active account: %s (%s)\n", accts[0].Slug, accts[0].Name) - return nil - } - chosen, err := pickAccountInteractive(bufio.NewScanner(os.Stdin), accts) - if err != nil { - return err - } - hc.ActiveAccountID = chosen.Id - hc.ActiveAccountSlug = chosen.Slug - fmt.Printf("Active account: %s (%s)\n", chosen.Slug, chosen.Name) - return nil -} - -func newLogoutCmd() *cobra.Command { - return &cobra.Command{ - Use: "logout", - Short: "Remove stored credentials", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, _, _ := cr.Resolve(host) - delete(cr.Hosts, resolvedHost) - return cr.Save() - }, - } -} diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go deleted file mode 100644 index 0b9a656..0000000 --- a/cmd/ninja/cmd/plugin.go +++ /dev/null @@ -1,1238 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "io" - "os" - "os/exec" - "regexp" - "sort" - "strconv" - "strings" - - "connectrpc.com/connect" - "github.com/spf13/cobra" - - core "git.dev.alexdunmow.com/block/core/plugin" - - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/archive" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient" - v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1" -) - -func newPluginCmd() *cobra.Command { - c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"} - c.AddCommand( - newPluginInitCmd(), - newPluginBuildCmd(), - newPluginVerifyCmd(), - newPluginPublishCmd(), - newPluginStatusCmd(), - newPluginListCmd(), - newPluginDeleteCmd(), - newPluginDeleteVersionCmd(), - newPluginBumpCmd(), - newPluginVersionCmd(), - newPluginTagsCmd(), - ) - return c -} - -func newPluginListCmd() *cobra.Command { - var publicOnly, privateOnly bool - cmd := &cobra.Command{ - Use: "list", - Short: "List your plugins, sectioned by public scope and active-account private namespace", - RunE: func(c *cobra.Command, _ []string) error { - cli, _, _, hc, err := resolveClient(c) - if err != nil { - return err - } - ctx := context.Background() - if !privateOnly { - printPublicSection(ctx, cli) - } - if !publicOnly { - printPrivateSection(ctx, cli, hc) - } - return nil - }, - } - cmd.Flags().BoolVar(&publicOnly, "public-only", false, "Only show plugins under public scopes") - cmd.Flags().BoolVar(&privateOnly, "private-only", false, "Only show plugins under the active account's @private namespace") - return cmd -} - -func printPublicSection(ctx context.Context, cli *orchclient.Client) { - fmt.Println("Public") - scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{})) - if err != nil { - fmt.Printf(" (error: %v)\n", err) - return - } - if len(scopes.Msg.Scopes) == 0 { - fmt.Println(" (no scopes — create one with `ninja scope create`)") - return - } - for _, s := range scopes.Msg.Scopes { - gs, err := cli.Scope.GetScope(ctx, connect.NewRequest(&v1.GetScopeRequest{Slug: s.Slug})) - if err != nil { - fmt.Printf(" @%s — (error: %v)\n", s.Slug, err) - continue - } - if len(gs.Msg.Plugins) == 0 { - fmt.Printf(" @%s — (no plugins)\n", s.Slug) - continue - } - for _, p := range gs.Msg.Plugins { - fmt.Printf(" @%s/%s [%s]\n", s.Slug, p.Name, visibilityLabel(p.Visibility)) - } - } -} - -func newPluginDeleteCmd() *cobra.Command { - var assumeYes bool - cmd := &cobra.Command{ - Use: "delete <@private/name|name>", - Short: "Delete a private plugin from your active account (all versions)", - Long: `Delete a private plugin owned by the active account, along with all its -versions. The server refuses to delete a plugin while any site in the -account has it installed; uninstall those first. - -Public plugins cannot be deleted via this command.`, - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - cli, _, _, hc, err := resolveClient(c) - if err != nil { - return err - } - if hc.ActiveAccountID == "" { - return fmt.Errorf("no active account; run `ninja account set `") - } - name, err := parsePrivateCoord(args[0]) - if err != nil { - return err - } - if !assumeYes { - return fmt.Errorf("refusing to delete without --yes") - } - _, err = cli.Reg.DeletePrivatePlugin(context.Background(), - connect.NewRequest(&v1.DeletePrivatePluginRequest{ - AccountId: hc.ActiveAccountID, - PluginName: name, - })) - if err != nil { - return fmt.Errorf("delete: %w", err) - } - fmt.Printf("Deleted @%s/%s\n", core.PrivateScopeSlug, name) - return nil - }, - } - cmd.Flags().BoolVar(&assumeYes, "yes", false, "Confirm the destructive action") - return cmd -} - -func newPluginDeleteVersionCmd() *cobra.Command { - var assumeYes bool - var version string - cmd := &cobra.Command{ - Use: "delete-version <@private/name|name>", - Short: "Delete one version of a private plugin", - Args: cobra.ExactArgs(1), - RunE: func(c *cobra.Command, args []string) error { - cli, _, _, hc, err := resolveClient(c) - if err != nil { - return err - } - if hc.ActiveAccountID == "" { - return fmt.Errorf("no active account; run `ninja account set `") - } - if version == "" { - return fmt.Errorf("--version is required") - } - name, err := parsePrivateCoord(args[0]) - if err != nil { - return err - } - if !assumeYes { - return fmt.Errorf("refusing to delete without --yes") - } - _, err = cli.Reg.DeletePrivatePluginVersion(context.Background(), - connect.NewRequest(&v1.DeletePrivatePluginVersionRequest{ - AccountId: hc.ActiveAccountID, - PluginName: name, - Version: version, - })) - if err != nil { - return fmt.Errorf("delete-version: %w", err) - } - fmt.Printf("Deleted @%s/%s@%s\n", core.PrivateScopeSlug, name, version) - return nil - }, - } - cmd.Flags().BoolVar(&assumeYes, "yes", false, "Confirm the destructive action") - cmd.Flags().StringVar(&version, "version", "", "Version to delete (required)") - return cmd -} - -// visibilityLabel converts the proto PluginVisibility enum into the -// lowercase, user-facing form ("private", "public", ...). Kept here in the -// CLI rather than in core/plugin so that external consumers of core/plugin -// don't transitively pull in core/internal/api/... — every such pull-in -// races with the orchestrator's own generated bindings of the same proto -// file at proto-registration time. -func visibilityLabel(v v1.PluginVisibility) string { - switch v { - case v1.PluginVisibility_PLUGIN_VISIBILITY_PRIVATE: - return "private" - case v1.PluginVisibility_PLUGIN_VISIBILITY_UNDER_REVIEW: - return "under_review" - case v1.PluginVisibility_PLUGIN_VISIBILITY_PUBLIC, - v1.PluginVisibility_PLUGIN_VISIBILITY_UNSPECIFIED: - return "public" - case v1.PluginVisibility_PLUGIN_VISIBILITY_REJECTED: - return "rejected" - case v1.PluginVisibility_PLUGIN_VISIBILITY_TAKEN_DOWN: - return "taken_down" - } - return "unknown" -} - -// parsePrivateCoord accepts either a bare plugin name ("myplugin") or the -// canonical "@private/name" form and returns just the plugin name. Any other -// scope is rejected to make accidental deletion of public plugins impossible. -func parsePrivateCoord(s string) (string, error) { - s = strings.TrimSpace(s) - if !strings.HasPrefix(s, "@") { - return s, nil - } - rest := strings.TrimPrefix(s, "@") - before, after, ok := strings.Cut(rest, "/") - if !ok { - return "", fmt.Errorf("expected @%s/, got %q", core.PrivateScopeSlug, s) - } - if before != core.PrivateScopeSlug { - return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, before) - } - return after, nil -} - -func printPrivateSection(ctx context.Context, cli *orchclient.Client, hc creds.HostCreds) { - if hc.ActiveAccountID == "" { - fmt.Println("\nPrivate — (no active account; set one with `ninja account set `)") - return - } - fmt.Printf("\nPrivate — account: %s\n", hc.ActiveAccountSlug) - resp, err := cli.Reg.ListPrivatePlugins(ctx, connect.NewRequest(&v1.ListPrivatePluginsRequest{ - AccountId: hc.ActiveAccountID, - })) - if err != nil { - fmt.Printf(" (error: %v)\n", err) - return - } - if len(resp.Msg.Plugins) == 0 { - fmt.Println(" (none — publish one with `ninja plugin publish --private`)") - return - } - for _, p := range resp.Msg.Plugins { - latest := p.ChannelVersions["latest"] - if latest == "" { - latest = "(no versions)" - } - fmt.Printf(" @%s/%s %s\n", core.PrivateScopeSlug, p.Plugin.Name, latest) - } -} - -func newPluginInitCmd() *cobra.Command { - var scope, name string - var private bool - cmd := &cobra.Command{ - Use: "init", - Short: "Create a plugin in the registry and add a git remote", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - ctx := context.Background() - scanner := bufio.NewScanner(os.Stdin) - - // Read existing plugin.mod (if any) so prompts can default to its - // values. Missing or unparseable file is fine — we just start from - // empty defaults. - existing := &core.ModFile{} - if data, err := os.ReadFile("plugin.mod"); err == nil { - if m, err := core.ParseModFull(data); err == nil && m != nil { - existing = m - } - } - - // --private forces the plugin into the account-scoped @private - // namespace and skips scope selection entirely. Inherit a previous - // `private = true` from the mod file so re-running init in a - // private plugin's directory doesn't silently flip it public. - if existing.Plugin.Private { - private = true - } - - switch { - case private: - if hc.ActiveAccountID == "" { - return fmt.Errorf("--private requires an active account; run `ninja login` or `ninja account set `") - } - scope = "@" + core.PrivateScopeSlug - case scope != "": - scope, err = parseScope(scope) - if err != nil { - return err - } - case existing.Plugin.Scope != "": - scope = "@" + strings.TrimPrefix(existing.Plugin.Scope, "@") - default: - // Scope precedence: --scope flag > plugin.mod scope > interactive prompt. - scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner) - if err != nil { - return err - } - } - - // Name precedence: --name flag > plugin.mod name > prompt. - if name == "" { - if existing.Plugin.Name != "" { - fmt.Printf("Plugin name [%s]: ", existing.Plugin.Name) - } else { - fmt.Print("Plugin name: ") - } - if !scanner.Scan() { - return fmt.Errorf("cancelled") - } - input := strings.TrimSpace(scanner.Text()) - if input != "" { - name = input - } else { - name = existing.Plugin.Name - } - if name == "" { - return fmt.Errorf("plugin name is required") - } - } - rawName := name - name = strings.ToLower(name) - - displayDefault := existing.Plugin.DisplayName - if displayDefault == "" { - displayDefault = rawName - } - fmt.Printf("Display name [%s]: ", displayDefault) - displayName := displayDefault - if scanner.Scan() { - if v := strings.TrimSpace(scanner.Text()); v != "" { - displayName = v - } - } - - descLabel := "Description (one-line summary, optional)" - if existing.Plugin.Description != "" { - descLabel = fmt.Sprintf("Description [%s]", truncate(existing.Plugin.Description, 60)) - } - fmt.Printf("%s: ", descLabel) - description := existing.Plugin.Description - if scanner.Scan() { - if v := strings.TrimSpace(scanner.Text()); v != "" { - description = v - } - } - - kind, err := promptKindWithDefault(scanner, existing.Plugin.Kind) - if err != nil { - return err - } - var cats []string - if kind == "plugin" { - cats, err = promptCategoriesWithDefault(ctx, cli, scanner, existing.Plugin.Categories) - if err != nil { - return err - } - } - tags, err := promptTagsWithDefault(ctx, cli, scanner, kind, existing.Plugin.Tags) - if err != nil { - return err - } - - createReq := &v1.CreatePluginRequest{ - ScopeSlug: scopeAPISlug(scope), - Name: name, - DisplayName: displayName, - Description: description, - Kind: kind, - Categories: cats, - } - if private { - createReq.Visibility = v1.PluginVisibility_PLUGIN_VISIBILITY_PRIVATE - createReq.ActiveAccountId = hc.ActiveAccountID - } - if _, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(createReq)); err != nil { - return err - } - fmt.Printf("\nCreated %s/%s\n", scope, name) - - // For private plugins the scope line in plugin.mod is informational - // only — coord resolution always rewrites to @private. Persist an - // empty scope so future re-runs don't see a misleading scope value. - modScope := scope - if private { - modScope = "" - } - if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, tags, private); err != nil { - return err - } - fmt.Println("plugin.mod updated") - - if _, err := os.Stat(".git"); err == nil { - if err := autoCommitPluginMod("Add plugin.mod"); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err) - } - } else { - fmt.Println("Not in a git repo - run `git init && git commit` before `ninja plugin publish`") - } - return nil - }, - } - cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)") - cmd.Flags().StringVar(&name, "name", "", "Plugin name") - cmd.Flags().BoolVar(&private, "private", false, "Create a private plugin under your active account's @private namespace") - return cmd -} - -func promptScope(ctx context.Context, cli *orchclient.Client, cr *creds.Credentials, host string, hc creds.HostCreds, scanner *bufio.Scanner) (string, error) { - if hc.DefaultScope != "" { - fmt.Printf("Using default scope: %s (override with --scope)\n", hc.DefaultScope) - return hc.DefaultScope, nil - } - - scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{})) - if err != nil { - return "", fmt.Errorf("listing scopes: %w", err) - } - - fmt.Println() - fmt.Println("A scope is your organisation or personal namespace for plugins (like @mycompany).") - fmt.Println() - - var scope string - if len(scopes.Msg.Scopes) == 0 { - fmt.Println("You don't have any scopes yet. Let's create one.") - fmt.Println() - s, err := createScopeInline(ctx, cli, scanner) - if err != nil { - return "", err - } - scope = s - } else { - fmt.Println("Your scopes:") - for i, s := range scopes.Msg.Scopes { - fmt.Printf(" %d. @%s — %s\n", i+1, s.Slug, s.DisplayName) - } - fmt.Println() - fmt.Print("Select a scope [1]: ") - if !scanner.Scan() { - return "", fmt.Errorf("cancelled") - } - input := strings.TrimSpace(scanner.Text()) - - if input == "" { - scope = "@" + scopes.Msg.Scopes[0].Slug - } else if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) { - scope = "@" + scopes.Msg.Scopes[n-1].Slug - } else { - return "", fmt.Errorf("invalid selection: %s", input) - } - } - - fmt.Printf("\nSave %s as your default scope? [Y/n]: ", scope) - if scanner.Scan() { - ans := strings.ToLower(strings.TrimSpace(scanner.Text())) - if ans == "" || ans == "y" || ans == "yes" { - hc.DefaultScope = scope - cr.Hosts[host] = hc - if err := cr.Save(); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err) - } else { - fmt.Println("Default scope saved.") - } - } - } - - return scope, nil -} - -func createScopeInline(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner) (string, error) { - fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).") - fmt.Println("It appears in plugin names like @acme/my-plugin.") - fmt.Println() - fmt.Print("Scope slug (lowercase letters, numbers, dashes): ") - if !scanner.Scan() { - return "", fmt.Errorf("cancelled") - } - slug, err := parseScope(scanner.Text()) - if err != nil { - return "", err - } - - fmt.Printf("Display name [%s]: ", scopeAPISlug(slug)) - if !scanner.Scan() { - return "", fmt.Errorf("cancelled") - } - displayName := strings.TrimSpace(scanner.Text()) - if displayName == "" { - displayName = scopeAPISlug(slug) - } - - _, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{ - Slug: scopeAPISlug(slug), - DisplayName: displayName, - })) - if err != nil { - return "", err - } - fmt.Printf("Created scope %s\n\n", slug) - return slug, nil -} - -// promptKindWithDefault prompts for plugin kind. When current is "plugin" or -// "theme", that becomes the empty-input default (and the [n] hint reflects it). -func promptKindWithDefault(scanner *bufio.Scanner, current string) (string, error) { - defaultIdx := "1" - switch current { - case "theme": - defaultIdx = "2" - case "plugin", "": - defaultIdx = "1" - } - fmt.Println("Kind: 1) plugin 2) theme") - fmt.Printf("Select [%s]: ", defaultIdx) - if !scanner.Scan() { - return "", fmt.Errorf("cancelled") - } - v := strings.TrimSpace(scanner.Text()) - if v == "" { - v = defaultIdx - } - switch v { - case "1", "plugin": - return "plugin", nil - case "2", "theme": - return "theme", nil - default: - return "", fmt.Errorf("invalid kind: %s", v) - } -} - -// truncate clips a string to at most max runes, appending "…" if it was cut. -// Used to keep prompt labels readable when the existing description is long. -func truncate(s string, max int) string { - if len([]rune(s)) <= max { - return s - } - r := []rune(s) - return string(r[:max-1]) + "…" -} - -// promptCategoriesWithDefault lists the canonical categories and lets the user -// pick by number. When current is non-empty, those slugs are pre-selected: -// the prompt shows their numbers as the default, and empty input keeps them. -func promptCategoriesWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, current []string) ([]string, error) { - resp, err := cli.Reg.ListCategories(ctx, connect.NewRequest(&v1.ListCategoriesRequest{})) - if err != nil { - return nil, fmt.Errorf("list categories: %w", err) - } - cats := resp.Msg.Categories - if len(cats) == 0 { - return nil, nil - } - // Build the default indices string from `current` so the prompt shows e.g. - // "Select [9]: " when current is ["templates"] and templates is the 9th - // entry. - slugToIdx := make(map[string]int, len(cats)) - for i, c := range cats { - slugToIdx[c.Slug] = i + 1 - } - var defaultNums []string - for _, slug := range current { - if i, ok := slugToIdx[slug]; ok { - defaultNums = append(defaultNums, strconv.Itoa(i)) - } - } - defaultLabel := strings.Join(defaultNums, ",") - - fmt.Println("Categories (comma-separated numbers, or blank to keep current):") - for i, c := range cats { - marker := " " - if _, picked := pickedSet(current)[c.Slug]; picked { - marker = "*" - } - fmt.Printf(" %d.%s%s — %s\n", i+1, marker, c.Slug, c.DisplayName) - } - if defaultLabel != "" { - fmt.Printf("Select [%s]: ", defaultLabel) - } else { - fmt.Print("Select: ") - } - if !scanner.Scan() { - return nil, fmt.Errorf("cancelled") - } - raw := strings.TrimSpace(scanner.Text()) - if raw == "" { - // Empty input → keep current (or none, if there's no current). - return current, nil - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - n, err := strconv.Atoi(strings.TrimSpace(p)) - if err != nil || n < 1 || n > len(cats) { - return nil, fmt.Errorf("invalid category selection: %s", p) - } - out = append(out, cats[n-1].Slug) - } - return out, nil -} - -// fetchPopularTagsForPrompt returns a comma-joined "tag (count)" string of the -// top tags for the given kind, or "" if the orchestrator lookup fails (e.g. -// offline, unauthenticated, RPC error). Callers must tolerate an empty return -// and surface their own user-facing fallback message. -func fetchPopularTagsForPrompt(ctx context.Context, cli *orchclient.Client, kind string) string { - resp, err := cli.Reg.ListTags(ctx, connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20})) - if err != nil { - return "" - } - if len(resp.Msg.Tags) == 0 { - return "" - } - parts := make([]string, 0, len(resp.Msg.Tags)) - for _, t := range resp.Msg.Tags { - parts = append(parts, fmt.Sprintf("%s (%d)", t.Tag, t.Count)) - } - return strings.Join(parts, ", ") -} - -// promptTagsWithDefault prompts the user for free-form tags. Best-effort fetches -// the top-20 most-used tags via ListTags(kind) to surface popular suggestions; -// if that call fails (offline, etc.) it falls back to a plain prompt with a -// one-line warning. Empty input keeps `current`. Validates with NormalizeTags; -// on error, prints the issue and reprompts (one re-try, then bails). -func promptTagsWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, kind string, current []string) ([]string, error) { - popular := fetchPopularTagsForPrompt(ctx, cli, kind) - - for attempt := range 2 { - if len(current) > 0 { - fmt.Printf("Tags (current: %s)\n", strings.Join(current, ", ")) - } else { - fmt.Println("Tags (current: none)") - } - if popular != "" { - fmt.Printf("Popular: %s\n", popular) - } else if attempt == 0 { - fmt.Println("(could not fetch popular tags — offline or unauthenticated)") - } - fmt.Print("Enter comma-separated tags (blank to keep current): ") - if !scanner.Scan() { - return nil, fmt.Errorf("cancelled") - } - raw := strings.TrimSpace(scanner.Text()) - if raw == "" { - return current, nil - } - parts := strings.Split(raw, ",") - out, err := core.NormalizeTags(parts) - if err == nil { - return out, nil - } - fmt.Printf(" %s\n", err.Error()) - if attempt == 0 { - fmt.Println(" Try again:") - } - } - return nil, fmt.Errorf("tag input failed validation after 2 attempts") -} - -// pickedSet returns a set of the given slugs for O(1) membership checks. -func pickedSet(slugs []string) map[string]struct{} { - s := make(map[string]struct{}, len(slugs)) - for _, x := range slugs { - s[x] = struct{}{} - } - return s -} - -func newPluginPublishCmd() *cobra.Command { - var channel string - var strict bool - var privateFlag bool - var bnpFile string - cmd := &cobra.Command{ - Use: "publish", - Short: "Publish a new version to the registry (source archive, or a prebuilt .bnp via --bnp)", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - - modBytes, err := os.ReadFile("plugin.mod") - if err != nil { - return fmt.Errorf("read plugin.mod: %w", err) - } - mod, err := core.ParseModFull(modBytes) - if err != nil { - return err - } - - // --private on the command line is sticky: persist it back to - // plugin.mod so the next publish keeps the visibility. The mod - // file is the source of truth at publish time; the flag is just - // the on-ramp. - private := mod.Plugin.Private || privateFlag - if private && !mod.Plugin.Private { - mod.Plugin.Private = true - if err := writeMod("plugin.mod", mod); err != nil { - return fmt.Errorf("persist private=true to plugin.mod: %w", err) - } - fmt.Fprintln(os.Stderr, "plugin.mod updated: private = true") - } - - switch { - case private: - if hc.ActiveAccountID == "" { - return fmt.Errorf("--private requires an active account; run `ninja login` or `ninja account set `") - } - if mod.Plugin.Scope != "" { - fmt.Fprintf(os.Stderr, - "warning: scope %q in plugin.mod is ignored because private = true; publishing as %s\n", - mod.Plugin.Scope, mod.Coords()) - } - if mod.Plugin.Name == "" || mod.Plugin.Version == "" { - return fmt.Errorf("plugin.mod must have name and version") - } - default: - if mod.Plugin.Scope == "" || mod.Plugin.Name == "" || mod.Plugin.Version == "" { - return fmt.Errorf("plugin.mod must have scope, name, and version") - } - } - - if err := checkRepoHasHEAD("."); err != nil { - return err - } - - // Backstop for the bump-then-publish flow when bump's auto-commit - // was skipped (--no-commit) or the version was hand-edited. Commits - // only plugin.mod; any other dirty paths are left for - // emitPublishWarnings (and ship via stash-create unless --strict). - if err := autoCommitPluginMod(fmt.Sprintf("bump to %s", mod.Plugin.Version)); err != nil { - return fmt.Errorf("auto-commit plugin.mod: %w", err) - } - - if err := emitPublishWarnings(".", !strict, os.Stderr); err != nil { - return err - } - - // WO-WZ-011: --bnp uploads a prebuilt wasm artifact (from - // `ninja plugin build`) instead of a source archive. The orchestrator - // verifies the .bnp layout/ABI/name/version server-side and records - // its abi_version. Without --bnp, publish keeps shipping a source - // archive (compiled in-container by the CMS reconciler) for - // backwards compatibility until the CMS install path is wasm-native. - var ( - archiveBytes []byte - artifactKind = "source archive" - ) - if bnpFile != "" { - archiveBytes, err = os.ReadFile(bnpFile) - if err != nil { - return fmt.Errorf("read --bnp file: %w", err) - } - artifactKind = ".bnp artifact" - } else { - archiveBytes, err = archive.BuildSourceArchive(".") - if err != nil { - return fmt.Errorf("build archive: %w", err) - } - } - - ctx := context.Background() - getReq := &v1.GetPluginRequest{ - ScopeSlug: mod.Plugin.Scope, - Name: mod.Plugin.Name, - } - if private { - getReq.ScopeSlug = core.PrivateScopeSlug - getReq.ActiveAccountId = hc.ActiveAccountID - } - pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(getReq)) - if err != nil { - return fmt.Errorf("get plugin: %w", err) - } - - readme, _ := os.ReadFile("README.md") - changelog, _ := os.ReadFile("CHANGELOG.md") - - pubResp, err := cli.Pub.PublishVersion(ctx, connect.NewRequest(&v1.PublishVersionRequest{ - PluginId: pr.Msg.Plugin.Id, - Version: mod.Plugin.Version, - Channel: channel, - Archive: archiveBytes, - ReadmeMd: string(readme), - ChangelogMd: string(changelog), - })) - if err != nil { - return fmt.Errorf("publish: %w", err) - } - fmt.Printf("Published %s (%s, %d bytes)\n", mod.Coords(), artifactKind, len(archiveBytes)) - for _, w := range pubResp.Msg.Warnings { - fmt.Printf(" warning: %s\n", w) - } - return nil - }, - } - cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version") - cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: ship dirty trees via stash-create)") - cmd.Flags().BoolVar(&privateFlag, "private", false, "Publish as a private plugin under your active account's @private namespace") - cmd.Flags().StringVar(&bnpFile, "bnp", "", "Upload a prebuilt .bnp wasm artifact (from `ninja plugin build`) instead of a source archive") - return cmd -} - -func newPluginStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "List owned scopes and their plugins", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - ctx := context.Background() - scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{})) - if err != nil { - return err - } - if len(scopes.Msg.Scopes) == 0 { - fmt.Println("No scopes yet. Create one with: ninja scope create") - return nil - } - for _, s := range scopes.Msg.Scopes { - fmt.Printf("@%s - %s\n", s.Slug, s.DisplayName) - gs, err := cli.Scope.GetScope(ctx, connect.NewRequest(&v1.GetScopeRequest{Slug: s.Slug})) - if err != nil { - fmt.Printf(" (error: %v)\n", err) - continue - } - for _, p := range gs.Msg.Plugins { - fmt.Printf(" @%s/%s [%s]\n", s.Slug, p.Name, visibilityLabel(p.Visibility)) - } - } - return nil - }, - } -} - -func newPluginBumpCmd() *cobra.Command { - var setVersion string - var noCommit bool - cmd := &cobra.Command{ - Use: "bump [major|minor|patch]", - Short: "Bump the version in plugin.mod (default: patch)", - Long: "Updates plugin.mod with a new version and auto-commits it. Pass --no-commit to skip the commit.", - Args: cobra.MaximumNArgs(1), - RunE: func(c *cobra.Command, args []string) error { - modBytes, err := os.ReadFile("plugin.mod") - if err != nil { - return fmt.Errorf("read plugin.mod: %w", err) - } - mod, err := core.ParseModFull(modBytes) - if err != nil { - return err - } - if mod.Plugin.Version == "" { - return fmt.Errorf("plugin.mod has no version") - } - old := mod.Plugin.Version - - var next string - if setVersion != "" { - if len(args) > 0 { - return fmt.Errorf("cannot combine --set with bump argument") - } - if _, _, _, err := core.ParseBaseSemver(setVersion); err != nil { - return err - } - next = setVersion - } else { - level := "patch" - if len(args) > 0 { - level = args[0] - } - next, err = core.BumpVersion(old, level) - if err != nil { - return err - } - } - - mod.Plugin.Version = next - if err := writeMod("plugin.mod", mod); err != nil { - return err - } - fmt.Printf("%s -> %s\n", old, next) - - if !noCommit { - if _, err := os.Stat(".git"); err == nil { - if err := autoCommitPluginMod(fmt.Sprintf("bump to %s", next)); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err) - } - } - } - return nil - }, - } - cmd.Flags().StringVar(&setVersion, "set", "", "Set explicit version (e.g. 0.5.0)") - cmd.Flags().BoolVar(&noCommit, "no-commit", false, "Don't auto-commit plugin.mod after bumping") - return cmd -} - -func newPluginVersionCmd() *cobra.Command { - var short bool - cmd := &cobra.Command{ - Use: "version", - Short: "Show local plugin version and registry channels", - RunE: func(c *cobra.Command, _ []string) error { - modBytes, err := os.ReadFile("plugin.mod") - if err != nil { - return fmt.Errorf("read plugin.mod: %w", err) - } - mod, err := core.ParseModFull(modBytes) - if err != nil { - return err - } - if short { - fmt.Println(mod.Plugin.Version) - return nil - } - - local := mod.Plugin.Version - if local == "" { - local = "(unset)" - } - fmt.Printf("local: %s\n", local) - - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - fmt.Printf("(registry: %v)\n", err) - return nil - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - fmt.Printf("(registry: %v)\n", err) - return nil - } - if mod.Plugin.Scope == "" || mod.Plugin.Name == "" { - fmt.Println("(registry: plugin.mod missing scope or name)") - return nil - } - - cli := orchclient.New(resolvedHost, hc.Token) - pr, err := cli.Reg.GetPlugin(context.Background(), connect.NewRequest(&v1.GetPluginRequest{ - ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name, - })) - if err != nil { - fmt.Printf("(registry: %v)\n", err) - return nil - } - - verByString := make(map[string]*v1.Version, len(pr.Msg.Versions)) - for _, v := range pr.Msg.Versions { - verByString[v.Version] = v - } - - names := make([]string, 0, len(pr.Msg.Channels)) - for ch := range pr.Msg.Channels { - names = append(names, ch) - } - sort.Slice(names, func(i, j int) bool { - if names[i] == "latest" { - return true - } - if names[j] == "latest" { - return false - } - return names[i] < names[j] - }) - for _, ch := range names { - ver := pr.Msg.Channels[ch] - date := "" - if v, ok := verByString[ver]; ok && v.PublishedAt != nil { - date = " (published " + v.PublishedAt.AsTime().Format("2006-01-02") + ")" - } - fmt.Printf("%-8s %s%s\n", ch+":", ver, date) - } - return nil - }, - } - cmd.Flags().BoolVar(&short, "short", false, "Print only the local version") - return cmd -} - -var scopeSlugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{2,}$`) - -func parseScope(input string) (string, error) { - raw := strings.TrimPrefix(strings.TrimSpace(input), "@") - if raw == "" { - return "", fmt.Errorf("scope is required") - } - if !scopeSlugRe.MatchString(raw) { - return "", fmt.Errorf("invalid scope %q: must be at least 3 characters, lowercase letters, numbers, and dashes", raw) - } - return "@" + raw, nil -} - -func scopeAPISlug(scope string) string { - return strings.TrimPrefix(scope, "@") -} - -func runCmd(name string, args ...string) error { - c := exec.Command(name, args...) - c.Stderr = os.Stderr - return c.Run() -} - -// checkRepoHasHEAD returns a friendlier error than the raw git failure when -// the publish flow is invoked in a freshly-initialised repository that has -// no commits yet. Without this, `git stash create` reports "You do not have -// the initial commit yet" via exit status 128, which surfaces as an internal -// failure to the user. -func checkRepoHasHEAD(repoDir string) error { - cmd := exec.Command("git", "rev-parse", "--verify", "HEAD") - cmd.Dir = repoDir - if err := cmd.Run(); err != nil { - return fmt.Errorf("no commits in repository; run `git add . && git commit` before publishing") - } - return nil -} - -// submodulePaths returns the configured submodule paths from .gitmodules. -// Reading the file directly (rather than running `git submodule status`) means -// we detect submodules that have been declared but not yet initialised. -func submodulePaths(repoDir string) []string { - cmd := exec.Command("git", "config", "--file", ".gitmodules", "--get-regexp", `submodule\..*\.path`) - cmd.Dir = repoDir - out, err := cmd.Output() - if err != nil { - return nil - } - var paths []string - for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { - // each line is "submodule..path " - fields := strings.Fields(line) - if len(fields) >= 2 { - paths = append(paths, fields[len(fields)-1]) - } - } - return paths -} - -func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error { - const file = "plugin.mod" - existing, _ := os.ReadFile(file) - mod, _ := core.ParseModFull(existing) - if mod == nil { - mod = &core.ModFile{} - } - if mod.Plugin.Version == "" { - mod.Plugin.Version = "0.1.0" - } - mod.Plugin.Scope = scope - mod.Plugin.Name = name - mod.Plugin.DisplayName = displayName - mod.Plugin.Description = description - mod.Plugin.Kind = kind - mod.Plugin.Categories = categories - mod.Plugin.Tags = tags - mod.Plugin.Private = private - return writeMod(file, mod) -} - -func writeMod(path string, m *core.ModFile) error { - var b strings.Builder - b.WriteString("[plugin]\n") - fmt.Fprintf(&b, "name = %q\n", m.Plugin.Name) - if m.Plugin.DisplayName != "" { - fmt.Fprintf(&b, "display_name = %q\n", m.Plugin.DisplayName) - } - fmt.Fprintf(&b, "scope = %q\n", m.Plugin.Scope) - fmt.Fprintf(&b, "version = %q\n", m.Plugin.Version) - if m.Plugin.Description != "" { - fmt.Fprintf(&b, "description = %q\n", m.Plugin.Description) - } - if m.Plugin.Kind != "" { - fmt.Fprintf(&b, "kind = %q\n", m.Plugin.Kind) - } - if len(m.Plugin.Categories) > 0 { - quoted := make([]string, len(m.Plugin.Categories)) - for i, c := range m.Plugin.Categories { - quoted[i] = fmt.Sprintf("%q", c) - } - fmt.Fprintf(&b, "categories = [%s]\n", strings.Join(quoted, ", ")) - } - if len(m.Plugin.Tags) > 0 { - quoted := make([]string, len(m.Plugin.Tags)) - for i, t := range m.Plugin.Tags { - quoted[i] = fmt.Sprintf("%q", t) - } - fmt.Fprintf(&b, "tags = [%s]\n", strings.Join(quoted, ", ")) - } - if m.Plugin.Private { - b.WriteString("private = true\n") - } - if m.Plugin.DataDir { - b.WriteString("data_dir = true\n") - } - if m.Compatibility != nil { - b.WriteString("\n[compatibility]\n") - fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore) - } - for _, r := range m.Requires { - b.WriteString("\n[[requires]]\n") - fmt.Fprintf(&b, "name = %q\n", r.Name) - fmt.Fprintf(&b, "version = %q\n", r.Version) - } - return os.WriteFile(path, []byte(b.String()), 0o644) -} - -// gitignoredTrackedWarning writes a warning to w if any tracked files -// in repoDir match the active .gitignore — those files still ship in the -// archive, which is almost always not what the author wants. -func gitignoredTrackedWarning(repoDir string, w io.Writer) { - cmd := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard") - cmd.Dir = repoDir - out, _ := cmd.Output() - names := strings.TrimSpace(string(out)) - if names == "" { - return - } - _, _ = fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:") - for n := range strings.SplitSeq(names, "\n") { - _, _ = fmt.Fprintln(w, " "+n) - } - _, _ = fmt.Fprintln(w, " (run `git rm --cached ` to drop)") -} - -// untrackedFilesWarning writes a warning to w listing untracked files in -// repoDir. Fires on the dirty-tree publish path because `git stash create` -// only captures tracked content, so untracked files silently vanish from the -// archive. -func untrackedFilesWarning(repoDir string, w io.Writer) { - cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard") - cmd.Dir = repoDir - out, _ := cmd.Output() - names := strings.TrimSpace(string(out)) - if names == "" { - return - } - _, _ = fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:") - for n := range strings.SplitSeq(names, "\n") { - _, _ = fmt.Fprintln(w, " "+n) - } - _, _ = fmt.Fprintln(w, " (run `git add ` if they should be shipped)") -} - -// emitPublishWarnings runs the publish-time warning helpers in the order the -// CLI expects: -// -// 1. gitignoredTrackedWarning is unconditional so the developer sees it even -// when the publish is about to abort under --strict. -// 2. If allowDirty is false (i.e. --strict) the working tree must be clean — -// a dirty tree returns an error and the remaining warnings (which are -// only useful on the proceeding-publish path) are skipped. -// 3. If allowDirty is true (the default) the untracked-files warning fires -// so the user knows those files won't be in the archive. -// 4. submoduleWarning is unconditional on the proceeding path because -// `git archive` does not recurse into submodules. -func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error { - gitignoredTrackedWarning(repoDir, w) - - if !allowDirty { - cmd := exec.Command("git", "status", "--porcelain") - cmd.Dir = repoDir - out, _ := cmd.Output() - if len(strings.TrimSpace(string(out))) > 0 { - return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict") - } - } else { - // `git stash create` only captures tracked content, so untracked - // files would be silently dropped from the archive. Warn loudly. - untrackedFilesWarning(repoDir, w) - } - - submoduleWarning(repoDir, w) - return nil -} - -// submoduleWarning writes a warning to w if repoDir contains submodules. -// `git archive` doesn't recurse into them so they ship as empty directories; -// detection is via .gitmodules so it fires even for uninitialised submodules. -func submoduleWarning(repoDir string, w io.Writer) { - paths := submodulePaths(repoDir) - if len(paths) == 0 { - return - } - _, _ = fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:") - for _, p := range paths { - _, _ = fmt.Fprintln(w, " "+p) - } - _, _ = fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)") -} - -// autoCommitPluginMod stages and commits plugin.mod with the given message, -// if plugin.mod differs from HEAD. No-op when there's nothing to commit. -// -// The commit is path-scoped (`git commit -- plugin.mod`) so any other staged -// paths in the index are left alone — important now that publish calls this -// automatically. -func autoCommitPluginMod(message string) error { - out, err := exec.Command("git", "status", "--porcelain", "plugin.mod").Output() - if err != nil { - return fmt.Errorf("git status: %w", err) - } - if strings.TrimSpace(string(out)) == "" { - return nil - } - if err := runCmd("git", "add", "plugin.mod"); err != nil { - return err - } - if err := runCmd("git", "commit", "-m", message, "--", "plugin.mod"); err != nil { - return err - } - fmt.Println("Committed plugin.mod") - return nil -} diff --git a/cmd/ninja/cmd/plugin_build.go b/cmd/ninja/cmd/plugin_build.go deleted file mode 100644 index c241f76..0000000 --- a/cmd/ninja/cmd/plugin_build.go +++ /dev/null @@ -1,140 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "strings" - - "github.com/spf13/cobra" - - "git.dev.alexdunmow.com/block/core/cmd/ninja/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 -.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 ", - 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(" │ 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]\n", - r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined) -} - -// 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]) -} diff --git a/cmd/ninja/cmd/plugin_build_test.go b/cmd/ninja/cmd/plugin_build_test.go deleted file mode 100644 index 9f85f12..0000000 --- a/cmd/ninja/cmd/plugin_build_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package cmd - -import ( - "archive/tar" - "context" - "os" - "path/filepath" - "slices" - "strings" - "testing" - - abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/bnp" - "github.com/klauspost/compress/zstd" - "google.golang.org/protobuf/proto" -) - -// fixtureDir resolves a testdata plugin fixture in the core module tree. -func fixtureDir(t *testing.T, name string) string { - t.Helper() - dir, err := filepath.Abs(filepath.Join("..", "..", "..", "plugin", "wasmguest", "testdata", name)) - if err != nil { - t.Fatalf("resolve fixture %s: %v", name, err) - } - if _, err := os.Stat(filepath.Join(dir, "plugin.mod")); err != nil { - t.Fatalf("fixture %s missing plugin.mod: %v", name, err) - } - return dir -} - -// TestPluginBuildAndVerify is the WO-WZ-009 acceptance e2e: build the WZ-002 -// fixture repo → a .bnp exists, `verify` passes, and its manifest decodes with -// the expected block keys and the data_dir grant from plugin.mod. -func TestPluginBuildAndVerify(t *testing.T) { - if testing.Short() { - t.Skip("compiles a wasm module; skipped in -short") - } - out := filepath.Join(t.TempDir(), "wasmfixture-0.0.1.bnp") - - res, err := bnp.Build(context.Background(), bnp.BuildOptions{ - Dir: fixtureDir(t, "fixture"), - Output: out, - }) - if err != nil { - t.Fatalf("build: %v", err) - } - if res.Name != "wasmfixture" || res.Version != "0.0.1" { - t.Errorf("identity = %q/%q", res.Name, res.Version) - } - if res.BlockCount != 1 || res.TemplateCount != 1 || res.AdminPages != 1 { - t.Errorf("counts blocks=%d templates=%d admin=%d", res.BlockCount, res.TemplateCount, res.AdminPages) - } - if !res.DataDir { - t.Errorf("data_dir grant not carried from plugin.mod into the manifest") - } - if !slices.Contains(res.Hooks, "load") { - t.Errorf("hooks = %v, want load present", res.Hooks) - } - if _, err := os.Stat(out); err != nil { - t.Fatalf("artifact not written: %v", err) - } - - // verify passes on the produced artifact. - vr, err := bnp.Verify(out) - if err != nil { - t.Fatalf("verify: %v", err) - } - if vr.Name != "wasmfixture" || vr.ABIVersion != 1 || !vr.DataDir { - t.Errorf("verify result = %+v", vr) - } - - // manifest decodes with expected block keys. - m, err := bnp.ReadManifest(out) - if err != nil { - t.Fatalf("read manifest: %v", err) - } - var keys []string - for _, b := range m.GetBlocks() { - keys = append(keys, b.GetKey()) - } - if !slices.Contains(keys, "wasmfixture:greeting") { - t.Errorf("block keys = %v, want wasmfixture:greeting", keys) - } - if !slices.Contains(m.GetTemplateKeys(), "wasmfixture-page") { - t.Errorf("template keys = %v, want wasmfixture-page", m.GetTemplateKeys()) - } - if !m.GetDataDir() { - t.Errorf("manifest.data_dir = false, want true") - } -} - -// TestPluginBuildDefaultOutputName confirms the default artifact name is -// -.bnp in the working directory. -func TestPluginBuildDefaultOutputName(t *testing.T) { - if testing.Short() { - t.Skip("compiles a wasm module; skipped in -short") - } - dir := fixtureDir(t, "fixture") // resolve to an absolute path before chdir - wd, _ := os.Getwd() - t.Cleanup(func() { _ = os.Chdir(wd) }) - tmp := t.TempDir() - if err := os.Chdir(tmp); err != nil { - t.Fatalf("chdir: %v", err) - } - res, err := bnp.Build(context.Background(), bnp.BuildOptions{Dir: dir}) - if err != nil { - t.Fatalf("build: %v", err) - } - if res.OutputPath != "wasmfixture-0.0.1.bnp" { - t.Errorf("default output = %q", res.OutputPath) - } - if _, err := os.Stat(filepath.Join(tmp, "wasmfixture-0.0.1.bnp")); err != nil { - t.Errorf("default artifact not in cwd: %v", err) - } -} - -// TestPluginBuildCapabilityInRegisterFails proves a plugin that reaches a host -// capability at describe time fails with an actionable error naming the call. -func TestPluginBuildCapabilityInRegisterFails(t *testing.T) { - if testing.Short() { - t.Skip("compiles a wasm module; skipped in -short") - } - out := filepath.Join(t.TempDir(), "capfixture.bnp") - _, err := bnp.Build(context.Background(), bnp.BuildOptions{ - Dir: fixtureDir(t, "capfixture"), - Output: out, - }) - if err == nil { - t.Fatal("expected build to fail on a describe-time capability call") - } - msg := err.Error() - if !strings.Contains(msg, "db.query") || !strings.Contains(msg, "manifest extraction") { - t.Errorf("error not actionable: %q (want it to name db.query + manifest extraction)", msg) - } - if _, statErr := os.Stat(out); statErr == nil { - t.Errorf("a .bnp must not be written when the build fails") - } -} - -// --- verify rejection classes (fast; no wasm compile) --- - -type fakeEntry struct { - name string - data []byte - typeflag byte -} - -// writeTarZst crafts an arbitrary tar.zst for the malformed-artifact tests. -func writeTarZst(t *testing.T, entries []fakeEntry) string { - t.Helper() - path := filepath.Join(t.TempDir(), "artifact.bnp") - f, err := os.Create(path) - if err != nil { - t.Fatalf("create: %v", err) - } - defer func() { _ = f.Close() }() - enc, _ := zstd.NewWriter(f) - tw := tar.NewWriter(enc) - for _, e := range entries { - typ := e.typeflag - if typ == 0 { - typ = tar.TypeReg - } - hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Size: int64(len(e.data))} - if typ == tar.TypeSymlink { - hdr.Linkname = string(e.data) - hdr.Size = 0 - } - if err := tw.WriteHeader(hdr); err != nil { - t.Fatalf("hdr %q: %v", e.name, err) - } - if typ == tar.TypeReg { - if _, err := tw.Write(e.data); err != nil { - t.Fatalf("write %q: %v", e.name, err) - } - } - } - _ = tw.Close() - _ = enc.Close() - return path -} - -func manifestBytes(t *testing.T, name string, abi uint32) []byte { - t.Helper() - b, err := proto.Marshal(&abiv1.PluginManifest{Name: name, Version: "1.0.0", AbiVersion: abi}) - if err != nil { - t.Fatalf("marshal manifest: %v", err) - } - return b -} - -func TestVerifyRejectsMalformed(t *testing.T) { - validMod := []byte("[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n") - - cases := []struct { - name string - entries []fakeEntry - wantMsg string - }{ - { - name: "missing manifest.pb", - entries: []fakeEntry{ - {name: "plugin.wasm", data: []byte("\x00asm")}, - {name: "plugin.mod", data: validMod}, - }, - wantMsg: "missing required manifest.pb", - }, - { - name: "undecodable manifest", - entries: []fakeEntry{ - {name: "plugin.wasm", data: []byte("\x00asm")}, - {name: "plugin.mod", data: validMod}, - {name: "manifest.pb", data: []byte("not-a-proto\xff\xff")}, - }, - wantMsg: "decode manifest.pb", - }, - { - name: "unsupported abi_version", - entries: []fakeEntry{ - {name: "plugin.wasm", data: []byte("\x00asm")}, - {name: "plugin.mod", data: validMod}, - {name: "manifest.pb", data: manifestBytes(t, "demo", 2)}, - }, - wantMsg: "unsupported abi_version 2", - }, - { - name: "empty manifest name", - entries: []fakeEntry{ - {name: "plugin.wasm", data: []byte("\x00asm")}, - {name: "plugin.mod", data: validMod}, - {name: "manifest.pb", data: manifestBytes(t, "", 1)}, - }, - wantMsg: "empty name", - }, - { - name: "name mismatch", - entries: []fakeEntry{ - {name: "plugin.wasm", data: []byte("\x00asm")}, - {name: "plugin.mod", data: validMod}, - {name: "manifest.pb", data: manifestBytes(t, "other", 1)}, - }, - wantMsg: "!= plugin.mod name", - }, - { - name: "absolute path entry", - entries: []fakeEntry{ - {name: "/etc/passwd", data: []byte("x")}, - }, - wantMsg: "absolute entry path", - }, - { - name: "traversal entry", - entries: []fakeEntry{ - {name: "../escape.txt", data: []byte("x")}, - }, - wantMsg: "traversal segment", - }, - { - name: "symlink entry", - entries: []fakeEntry{ - {name: "link", data: []byte("/etc/passwd"), typeflag: tar.TypeSymlink}, - }, - wantMsg: "non-regular entry", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - path := writeTarZst(t, tc.entries) - _, err := bnp.Verify(path) - if err == nil { - t.Fatalf("expected rejection, got nil") - } - if !strings.Contains(err.Error(), tc.wantMsg) { - t.Errorf("error %q does not mention %q", err.Error(), tc.wantMsg) - } - }) - } -} diff --git a/cmd/ninja/cmd/plugin_tags.go b/cmd/ninja/cmd/plugin_tags.go deleted file mode 100644 index 041c2a0..0000000 --- a/cmd/ninja/cmd/plugin_tags.go +++ /dev/null @@ -1,175 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "slices" - "sort" - "strings" - - "connectrpc.com/connect" - "github.com/spf13/cobra" - - core "git.dev.alexdunmow.com/block/core/plugin" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient" - v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1" -) - -func newPluginTagsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "tags", - Short: "Show current tags and popular tags from the registry", - RunE: func(c *cobra.Command, _ []string) error { - mod, err := readLocalMod() - if err != nil { - return err - } - if len(mod.Plugin.Tags) == 0 { - fmt.Println("Current tags: (none)") - } else { - fmt.Printf("Current tags: %s\n", strings.Join(mod.Plugin.Tags, ", ")) - } - - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return nil // not signed in is fine — silent best-effort - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return nil - } - cli := orchclient.New(resolvedHost, hc.Token) - line := fetchPopularTagsForList(cli, mod.Plugin.Kind) - fmt.Println(line) - return nil - }, - } - - cmd.AddCommand(&cobra.Command{ - Use: "add ...", - Short: "Add tags to the local plugin.mod (union with current)", - Args: cobra.MinimumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { return mutateTags("add", args) }, - }) - cmd.AddCommand(&cobra.Command{ - Use: "rm ...", - Short: "Remove tags from the local plugin.mod", - Args: cobra.MinimumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { return mutateTags("rm", args) }, - }) - cmd.AddCommand(&cobra.Command{ - Use: "set ...", - Short: "Replace all tags in the local plugin.mod", - Args: cobra.MinimumNArgs(1), - RunE: func(_ *cobra.Command, args []string) error { return mutateTags("set", args) }, - }) - cmd.AddCommand(&cobra.Command{ - Use: "clear", - Short: "Remove all tags from the local plugin.mod", - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { return mutateTags("clear", nil) }, - }) - - return cmd -} - -// mutateTags reads plugin.mod, computes the new tag set, normalises, and writes -// it back. Prints the before→after diff and a reminder to publish. -func mutateTags(op string, args []string) error { - mod, err := readLocalMod() - if err != nil { - return err - } - before := append([]string(nil), mod.Plugin.Tags...) - - var next []string - switch op { - case "add": - next = append(append([]string(nil), before...), args...) - case "rm": - drop := map[string]struct{}{} - for _, a := range args { - drop[strings.ToLower(strings.TrimSpace(a))] = struct{}{} - } - for _, t := range before { - if _, gone := drop[t]; !gone { - next = append(next, t) - } - } - case "set": - next = append([]string(nil), args...) - case "clear": - next = nil - default: - return fmt.Errorf("unknown tag op: %s", op) - } - - normalised, err := core.NormalizeTags(next) - if err != nil { - return err - } - - if err := writeLocalModTags(mod, normalised); err != nil { - return err - } - - sortedBefore := append([]string(nil), before...) - sortedAfter := append([]string(nil), normalised...) - sort.Strings(sortedBefore) - sort.Strings(sortedAfter) - fmt.Printf("Tags: [%s] → [%s]\n", strings.Join(sortedBefore, ", "), strings.Join(sortedAfter, ", ")) - if !slices.Equal(sortedBefore, sortedAfter) { - fmt.Println("Run 'ninja plugin publish' to push to the registry.") - } - return nil -} - -func readLocalMod() (*core.ModFile, error) { - b, err := os.ReadFile("plugin.mod") - if err != nil { - return nil, fmt.Errorf("read plugin.mod: %w", err) - } - mod, err := core.ParseModFull(b) - if err != nil { - return nil, fmt.Errorf("parse plugin.mod: %w", err) - } - return mod, nil -} - -// writeLocalModTags rewrites plugin.mod with the new tag set, preserving all -// other fields by reusing upsertPluginMod. -func writeLocalModTags(mod *core.ModFile, tags []string) error { - return upsertPluginMod( - mod.Plugin.Scope, - mod.Plugin.Name, - mod.Plugin.DisplayName, - mod.Plugin.Description, - mod.Plugin.Kind, - mod.Plugin.Categories, - tags, - mod.Plugin.Private, - ) -} - -// fetchPopularTagsForList returns a single user-facing line listing the most-used -// tags for the given kind. Renders "Popular: tag (count), ...", "Popular tags: -// (none yet)" when no tags exist on public plugins yet, or an "(unreachable)" -// notice if the RPC fails. -func fetchPopularTagsForList(cli *orchclient.Client, kind string) string { - resp, err := cli.Reg.ListTags(context.Background(), connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20})) - if err != nil { - return "(could not fetch popular tags — orchestrator unreachable)" - } - if len(resp.Msg.Tags) == 0 { - return "Popular tags: (none yet)" - } - parts := make([]string, len(resp.Msg.Tags)) - for i, t := range resp.Msg.Tags { - parts[i] = fmt.Sprintf("%s (%d)", t.Tag, t.Count) - } - return "Popular: " + strings.Join(parts, ", ") -} - diff --git a/cmd/ninja/cmd/plugin_test.go b/cmd/ninja/cmd/plugin_test.go deleted file mode 100644 index e4870fc..0000000 --- a/cmd/ninja/cmd/plugin_test.go +++ /dev/null @@ -1,637 +0,0 @@ -package cmd - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - core "git.dev.alexdunmow.com/block/core/plugin" -) - -func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - - err := checkRepoHasHEAD(dir) - if err == nil { - t.Fatal("expected error for repo with no commits, got nil") - } - if !strings.Contains(err.Error(), "no commits in repository") { - t.Errorf("error %q should mention 'no commits in repository'", err.Error()) - } - if !strings.Contains(err.Error(), "git commit") { - t.Errorf("error %q should suggest `git commit`", err.Error()) - } -} - -func TestCheckRepoHasHEAD_WithCommitReturnsNil(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "f") - runGit(t, dir, "commit", "-qm", "init") - - if err := checkRepoHasHEAD(dir); err != nil { - t.Errorf("expected nil for repo with a commit, got %v", err) - } -} - -func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - - t.Chdir(dir) - if err := autoCommitPluginMod("Add plugin.mod"); err != nil { - t.Fatalf("autoCommitPluginMod: %v", err) - } - - subject := gitLogSubject(t, dir) - if subject != "Add plugin.mod" { - t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject) - } -} - -func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "plugin.mod") - runGit(t, dir, "commit", "-qm", "seed") - - beforeSHA := gitHeadSHA(t, dir) - - t.Chdir(dir) - if err := autoCommitPluginMod("Add plugin.mod"); err != nil { - t.Fatalf("autoCommitPluginMod: %v", err) - } - - afterSHA := gitHeadSHA(t, dir) - if afterSHA != beforeSHA { - t.Errorf("expected no new commit, HEAD moved %s -> %s", beforeSHA, afterSHA) - } -} - -func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - initialSHA := gitHeadSHA(t, dir) - runGit(t, dir, "checkout", "-q", initialSHA) - - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - - t.Chdir(dir) - if err := autoCommitPluginMod("Add plugin.mod"); err != nil { - t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err) - } - - afterSHA := gitHeadSHA(t, dir) - if afterSHA == initialSHA { - t.Fatalf("expected new commit on detached HEAD, HEAD still at %s", afterSHA) - } - - subject := gitLogSubject(t, dir) - if subject != "Add plugin.mod" { - t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject) - } - - parentCmd := exec.Command("git", "rev-parse", "HEAD^") - parentCmd.Dir = dir - parentOut, err := parentCmd.CombinedOutput() - if err != nil { - t.Fatalf("git rev-parse HEAD^: %v\n%s", err, parentOut) - } - parentSHA := strings.TrimSpace(string(parentOut)) - if parentSHA != initialSHA { - t.Errorf("expected new commit parent to be %s, got %s", initialSHA, parentSHA) - } -} - -func TestAutoCommitPluginMod_UsesProvidedMessage(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - - t.Chdir(dir) - if err := autoCommitPluginMod("bump to 0.3.0"); err != nil { - t.Fatalf("autoCommitPluginMod: %v", err) - } - - if got := gitLogSubject(t, dir); got != "bump to 0.3.0" { - t.Errorf("expected commit subject 'bump to 0.3.0', got %q", got) - } -} - -func TestAutoCommitPluginMod_LeavesOtherStagedPathsAlone(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - // Stage an unrelated change that publish should NOT sweep up into the - // plugin.mod auto-commit. - if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("scratch"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "other.txt") - - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - - t.Chdir(dir) - if err := autoCommitPluginMod("bump to 0.3.0"); err != nil { - t.Fatalf("autoCommitPluginMod: %v", err) - } - - // The new commit should touch plugin.mod only. - filesCmd := exec.Command("git", "show", "--name-only", "--pretty=", "HEAD") - filesCmd.Dir = dir - filesOut, err := filesCmd.CombinedOutput() - if err != nil { - t.Fatalf("git show: %v\n%s", err, filesOut) - } - files := strings.Fields(strings.TrimSpace(string(filesOut))) - if len(files) != 1 || files[0] != "plugin.mod" { - t.Errorf("expected commit to touch only plugin.mod, got %v", files) - } - - // other.txt should still be staged (waiting for the developer to deal with). - statusCmd := exec.Command("git", "status", "--porcelain", "other.txt") - statusCmd.Dir = dir - statusOut, _ := statusCmd.Output() - if !strings.HasPrefix(strings.TrimSpace(string(statusOut)), "A ") { - t.Errorf("expected other.txt to remain staged ('A '), got %q", string(statusOut)) - } -} - -func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - - t.Chdir(dir) - t.Setenv("PATH", "") - - err := autoCommitPluginMod("Add plugin.mod") - if err == nil { - t.Fatal("expected error when git is missing from PATH, got nil") - } - if !strings.Contains(err.Error(), "git") { - t.Errorf("error %q should mention 'git'", err.Error()) - } -} - -func gitLogSubject(t *testing.T, dir string) string { - t.Helper() - cmd := exec.Command("git", "log", "-1", "--pretty=%s") - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git log: %v\n%s", err, out) - } - return strings.TrimSpace(string(out)) -} - -func gitHeadSHA(t *testing.T, dir string) string { - t.Helper() - cmd := exec.Command("git", "rev-parse", "HEAD") - cmd.Dir = dir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git rev-parse: %v\n%s", err, out) - } - return strings.TrimSpace(string(out)) -} - -func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "secret.env") - runGit(t, dir, "commit", "-qm", "init") - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", ".gitignore") - runGit(t, dir, "commit", "-qm", "ignore") - - var buf bytes.Buffer - gitignoredTrackedWarning(dir, &buf) - - out := buf.String() - if !strings.Contains(out, "secret.env") { - t.Errorf("warning should list secret.env, got: %q", out) - } - if !strings.Contains(out, "git rm --cached") { - t.Errorf("warning should suggest `git rm --cached`, got: %q", out) - } - if !strings.HasSuffix(out, "\n") { - t.Errorf("warning should end with a newline (Fprintln), got: %q", out) - } -} - -func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - var buf bytes.Buffer - gitignoredTrackedWarning(dir, &buf) - if buf.Len() != 0 { - t.Errorf("expected empty output for clean repo, got: %q", buf.String()) - } -} - -func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil { - t.Fatal(err) - } - - var buf bytes.Buffer - untrackedFilesWarning(dir, &buf) - - out := buf.String() - if !strings.Contains(out, "notes.txt") { - t.Errorf("warning should list notes.txt, got: %q", out) - } - if !strings.Contains(out, "git add") { - t.Errorf("warning should suggest `git add`, got: %q", out) - } -} - -func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - var buf bytes.Buffer - untrackedFilesWarning(dir, &buf) - if buf.Len() != 0 { - t.Errorf("expected empty output, got: %q", buf.String()) - } -} - -func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - gitmodules := `[submodule "vendor/foo"] - path = vendor/foo - url = https://example.com/foo.git -` - if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil { - t.Fatal(err) - } - - var buf bytes.Buffer - submoduleWarning(dir, &buf) - - out := buf.String() - if !strings.Contains(out, "vendor/foo") { - t.Errorf("warning should mention vendor/foo, got: %q", out) - } - if !strings.Contains(out, "submodules") { - t.Errorf("warning should mention submodules, got: %q", out) - } -} - -func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - - var buf bytes.Buffer - submoduleWarning(dir, &buf) - if buf.Len() != 0 { - t.Errorf("expected empty output, got: %q", buf.String()) - } -} - -func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "secret.env") - runGit(t, dir, "commit", "-qm", "init") - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", ".gitignore") - runGit(t, dir, "commit", "-qm", "ignore") - - var buf bytes.Buffer - if err := emitPublishWarnings(dir, false, &buf); err != nil { - t.Fatalf("emitPublishWarnings: %v", err) - } - - out := buf.String() - if !strings.Contains(out, "secret.env") { - t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out) - } - if !strings.Contains(out, "match .gitignore") { - t.Errorf("expected gitignored-tracked warning fragment, got: %q", out) - } -} - -func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - - gitmodules := `[submodule "vendor/foo"] - path = vendor/foo - url = https://example.com/foo.git -` - if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", ".gitmodules") - runGit(t, dir, "commit", "-qm", "add submodule decl") - - var buf bytes.Buffer - if err := emitPublishWarnings(dir, false, &buf); err != nil { - t.Fatalf("emitPublishWarnings: %v", err) - } - - out := buf.String() - if !strings.Contains(out, "vendor/foo") { - t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out) - } - if !strings.Contains(out, "submodules") { - t.Errorf("expected submodule warning fragment, got: %q", out) - } -} - -func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { - t.Fatal(err) - } - runGit(t, dir, "add", "README.md") - runGit(t, dir, "commit", "-qm", "init") - if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil { - t.Fatal(err) - } - - t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) { - var buf bytes.Buffer - if err := emitPublishWarnings(dir, true, &buf); err != nil { - t.Fatalf("emitPublishWarnings: %v", err) - } - out := buf.String() - if !strings.Contains(out, "scratch.txt") { - t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out) - } - if !strings.Contains(out, "NOT be in the archive") { - t.Errorf("expected untracked-files warning fragment, got: %q", out) - } - }) - - t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) { - var buf bytes.Buffer - err := emitPublishWarnings(dir, false, &buf) - if err == nil { - t.Fatal("expected dirty-tree error, got nil") - } - if !strings.Contains(err.Error(), "working tree dirty") { - t.Errorf("expected dirty-tree error, got: %v", err) - } - if !strings.Contains(err.Error(), "--strict") { - t.Errorf("expected dirty-tree error to reference --strict, got: %v", err) - } - if strings.Contains(buf.String(), "untracked files will NOT be in the archive") { - t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String()) - } - }) -} - -func TestWriteMod_PrivateTrueSerializes(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "plugin.mod") - m := &core.ModFile{Plugin: core.ModPlugin{ - Name: "myplugin", - Scope: "themes", - Version: "0.1.0", - Private: true, - }} - if err := writeMod(path, m); err != nil { - t.Fatalf("writeMod: %v", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read back: %v", err) - } - if !strings.Contains(string(got), "private = true") { - t.Errorf("expected `private = true` line in plugin.mod, got:\n%s", got) - } -} - -func TestWriteMod_PrivateFalseOmitted(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "plugin.mod") - m := &core.ModFile{Plugin: core.ModPlugin{ - Name: "publicthing", - Scope: "themes", - Version: "0.1.0", - }} - if err := writeMod(path, m); err != nil { - t.Fatalf("writeMod: %v", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read back: %v", err) - } - if strings.Contains(string(got), "private") { - t.Errorf("expected no `private` line, got:\n%s", got) - } -} - -func TestParsePrivateCoord(t *testing.T) { - cases := []struct { - in string - want string - wantErr bool - }{ - {in: "myplugin", want: "myplugin"}, - {in: "@private/myplugin", want: "myplugin"}, - {in: " myplugin ", want: "myplugin"}, - {in: "@themes/myplugin", wantErr: true}, - {in: "@private", wantErr: true}, - } - for _, c := range cases { - got, err := parsePrivateCoord(c.in) - if c.wantErr { - if err == nil { - t.Errorf("parsePrivateCoord(%q) = %q, want error", c.in, got) - } - continue - } - if err != nil { - t.Errorf("parsePrivateCoord(%q) err: %v", c.in, err) - continue - } - if got != c.want { - t.Errorf("parsePrivateCoord(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -func TestMutateTags_AddRmSetClear(t *testing.T) { - dir := t.TempDir() - t.Chdir(dir) - - must := func(err error) { - t.Helper() - if err != nil { - t.Fatal(err) - } - } - - // Seed plugin.mod with no tags. - must(upsertPluginMod("themes", "darkpro", "Dark Pro", "Sleek dark theme", "theme", []string{}, nil, false)) - - // add - must(mutateTags("add", []string{"dark", "agency"})) - mod, err := readLocalMod() - must(err) - if len(mod.Plugin.Tags) != 2 || mod.Plugin.Tags[0] != "dark" || mod.Plugin.Tags[1] != "agency" { - t.Errorf("after add: %v", mod.Plugin.Tags) - } - - // add (dedupe + normalise) - must(mutateTags("add", []string{"Agency", "Serif"})) - mod, _ = readLocalMod() - if len(mod.Plugin.Tags) != 3 { - t.Errorf("after dedupe add: %v", mod.Plugin.Tags) - } - - // rm - must(mutateTags("rm", []string{"dark"})) - mod, _ = readLocalMod() - for _, tag := range mod.Plugin.Tags { - if tag == "dark" { - t.Errorf("after rm: dark still present: %v", mod.Plugin.Tags) - } - } - - // set - must(mutateTags("set", []string{"editorial"})) - mod, _ = readLocalMod() - if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "editorial" { - t.Errorf("after set: %v", mod.Plugin.Tags) - } - - // clear - must(mutateTags("clear", nil)) - mod, _ = readLocalMod() - if len(mod.Plugin.Tags) != 0 { - t.Errorf("after clear: %v", mod.Plugin.Tags) - } -} - -func TestMutateTags_RejectsInvalidNoWrite(t *testing.T) { - dir := t.TempDir() - t.Chdir(dir) - - if err := upsertPluginMod("themes", "x", "X", "", "theme", nil, []string{"dark"}, false); err != nil { - t.Fatal(err) - } - if err := mutateTags("add", []string{"BAD SPACE"}); err == nil { - t.Fatal("expected validation error") - } - mod, err := readLocalMod() - if err != nil { - t.Fatal(err) - } - if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "dark" { - t.Errorf("tags mutated despite error: %v", mod.Plugin.Tags) - } -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=t", - "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", - "GIT_COMMITTER_EMAIL=t@t", - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } -} diff --git a/cmd/ninja/cmd/root.go b/cmd/ninja/cmd/root.go deleted file mode 100644 index 14893bc..0000000 --- a/cmd/ninja/cmd/root.go +++ /dev/null @@ -1,21 +0,0 @@ -package cmd - -import "github.com/spf13/cobra" - -func NewRoot() *cobra.Command { - root := &cobra.Command{ - Use: "ninja", - Short: "BlockNinja developer CLI", - Long: "ninja is the developer-facing CLI for BlockNinja. First subcommand group: plugin.", - } - root.PersistentFlags().String("host", "", "Orchestrator base URL (default: from credentials or https://my.blockninjacms.com)") - root.AddCommand(newVersionCmd()) - root.AddCommand(newLoginCmd()) - root.AddCommand(newLogoutCmd()) - root.AddCommand(newWhoamiCmd()) - root.AddCommand(newPluginCmd()) - root.AddCommand(newThemeCmd()) - root.AddCommand(newScopeCmd()) - root.AddCommand(newAccountCmd()) - return root -} diff --git a/cmd/ninja/cmd/scope.go b/cmd/ninja/cmd/scope.go deleted file mode 100644 index fa4d1ed..0000000 --- a/cmd/ninja/cmd/scope.go +++ /dev/null @@ -1,230 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "os" - "strconv" - "strings" - - "connectrpc.com/connect" - "github.com/spf13/cobra" - - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds" - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient" - v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1" -) - -func newScopeCmd() *cobra.Command { - c := &cobra.Command{Use: "scope", Short: "Manage plugin scopes"} - c.AddCommand(newScopeCreateCmd(), newScopeListCmd(), newScopeDefaultCmd()) - return c -} - -func newScopeCreateCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "create [scope]", - Short: "Create a new scope (organisation namespace for plugins)", - Args: cobra.MaximumNArgs(1), - RunE: func(c *cobra.Command, args []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - ctx := context.Background() - scanner := bufio.NewScanner(os.Stdin) - - var slug string - if len(args) > 0 { - slug, err = parseScope(args[0]) - if err != nil { - return err - } - } else { - slug, err = promptScopeSlug(scanner) - if err != nil { - return err - } - } - - fmt.Printf("Display name [%s]: ", scopeAPISlug(slug)) - displayName := scopeAPISlug(slug) - if scanner.Scan() { - if v := strings.TrimSpace(scanner.Text()); v != "" { - displayName = v - } - } - - _, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{ - Slug: scopeAPISlug(slug), - DisplayName: displayName, - })) - if err != nil { - return err - } - fmt.Printf("Created scope %s\n", slug) - - fmt.Printf("Set %s as your default scope? [Y/n]: ", slug) - if scanner.Scan() { - ans := strings.ToLower(strings.TrimSpace(scanner.Text())) - if ans == "" || ans == "y" || ans == "yes" { - hc.DefaultScope = slug - cr.Hosts[resolvedHost] = hc - if err := cr.Save(); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err) - } else { - fmt.Println("Default scope saved.") - } - } - } - - return nil - }, - } - return cmd -} - -func promptScopeSlug(scanner *bufio.Scanner) (string, error) { - fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).") - fmt.Println("It appears in plugin names like @acme/my-plugin.") - fmt.Println() - fmt.Print("Scope slug (lowercase letters, numbers, dashes): ") - if !scanner.Scan() { - return "", fmt.Errorf("cancelled") - } - return parseScope(scanner.Text()) -} - -func newScopeDefaultCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "default", - Short: "Show or change the default scope", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - _, hc, err := cr.Resolve(host) - if err != nil { - return err - } - if hc.DefaultScope == "" { - fmt.Println("No default scope set. Run: ninja scope default set") - } else { - fmt.Println(hc.DefaultScope) - } - return nil - }, - } - cmd.AddCommand(newScopeDefaultSetCmd()) - return cmd -} - -func newScopeDefaultSetCmd() *cobra.Command { - return &cobra.Command{ - Use: "set", - Short: "Pick a default scope from your scopes", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - ctx := context.Background() - - scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{})) - if err != nil { - return err - } - if len(scopes.Msg.Scopes) == 0 { - fmt.Println("No scopes yet. Create one with: ninja scope create") - return nil - } - - fmt.Println("Your scopes:") - for i, s := range scopes.Msg.Scopes { - marker := "" - if "@"+s.Slug == hc.DefaultScope { - marker = " (current)" - } - fmt.Printf(" %d. @%s — %s%s\n", i+1, s.Slug, s.DisplayName, marker) - } - fmt.Println() - - scanner := bufio.NewScanner(os.Stdin) - fmt.Print("Select a scope: ") - if !scanner.Scan() { - return fmt.Errorf("cancelled") - } - input := strings.TrimSpace(scanner.Text()) - if input == "" { - return fmt.Errorf("cancelled") - } - - var scope string - if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) { - scope = "@" + scopes.Msg.Scopes[n-1].Slug - } else { - return fmt.Errorf("invalid selection: %s", input) - } - - hc.DefaultScope = scope - cr.Hosts[resolvedHost] = hc - if err := cr.Save(); err != nil { - return err - } - fmt.Printf("Default scope set to %s\n", scope) - return nil - }, - } -} - -func newScopeListCmd() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List your scopes", - RunE: func(c *cobra.Command, _ []string) error { - host, _ := c.Flags().GetString("host") - cr, err := creds.Load() - if err != nil { - return err - } - resolvedHost, hc, err := cr.Resolve(host) - if err != nil { - return err - } - cli := orchclient.New(resolvedHost, hc.Token) - ctx := context.Background() - - scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{})) - if err != nil { - return err - } - if len(scopes.Msg.Scopes) == 0 { - fmt.Println("No scopes yet. Create one with: ninja scope create") - return nil - } - for _, s := range scopes.Msg.Scopes { - marker := "" - if "@"+s.Slug == hc.DefaultScope { - marker = " (default)" - } - fmt.Printf("@%s — %s%s\n", s.Slug, s.DisplayName, marker) - } - return nil - }, - } -} diff --git a/cmd/ninja/cmd/theme.go b/cmd/ninja/cmd/theme.go deleted file mode 100644 index 408b034..0000000 --- a/cmd/ninja/cmd/theme.go +++ /dev/null @@ -1,108 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/spf13/cobra" - - core "git.dev.alexdunmow.com/block/core/plugin" - - "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/shot" -) - -func newThemeCmd() *cobra.Command { - c := &cobra.Command{Use: "theme", Short: "Theme authoring helpers (preview screenshots)"} - c.AddCommand(newThemeScreenshotCmd()) - return c -} - -func newThemeScreenshotCmd() *cobra.Command { - var gallery, slug, out, mobileOut, themeName, waitSelector string - var mobile bool - var width, height int - cmd := &cobra.Command{ - Use: "screenshot", - Short: "Render this theme's showcase page and write preview.png into the repo", - Long: `screenshot builds the gallery URL for this theme's showcase page -(rendered via the CMS render-only ?preview_template override), drives a headless -Chromium against it, and writes the captured PNG into the theme repo (preview.png, -git-tracked — the source of truth a human may later replace). - -The theme name defaults to plugin.mod's name in the current directory. Point ---gallery at the gallery CMS site that has this theme's .so loaded and the -showcase content seeded. --host selects the orchestrator (defaults to PROD); the -gallery URL is independent of --host but the flag is accepted for parity with -the rest of the CLI.`, - RunE: func(c *cobra.Command, _ []string) error { - if themeName == "" { - modBytes, err := os.ReadFile("plugin.mod") - if err != nil { - return fmt.Errorf("read plugin.mod (run from the theme repo, or pass --theme): %w", err) - } - mod, err := core.ParseModFull(modBytes) - if err != nil { - return err - } - if mod.Plugin.Kind != "theme" { - return fmt.Errorf("plugin.mod kind = %q, want theme", mod.Plugin.Kind) - } - themeName = mod.Plugin.Name - } - if gallery == "" { - return fmt.Errorf("--gallery is required (the gallery CMS site base, e.g. https://showcase.localdev.blockninjacms.com)") - } - - ctx := context.Background() - - desktopURL := shot.PreviewURL(gallery, slug, themeName) - fmt.Fprintf(os.Stderr, "capturing desktop: %s\n", desktopURL) - png, err := shot.Capture(ctx, shot.Options{ - URL: desktopURL, - Width: width, - Height: height, - WaitSelector: waitSelector, - Timeout: 45 * time.Second, - }) - if err != nil { - return err - } - if err := os.WriteFile(out, png, 0o644); err != nil { - return fmt.Errorf("write %s: %w", out, err) - } - fmt.Printf("wrote %s (%d bytes)\n", out, len(png)) - - if mobile { - mURL := shot.PreviewURL(gallery, slug, themeName) - fmt.Fprintf(os.Stderr, "capturing mobile: %s\n", mURL) - mpng, err := shot.Capture(ctx, shot.Options{ - URL: mURL, - Width: 390, - Height: 844, - WaitSelector: waitSelector, - Timeout: 45 * time.Second, - }) - if err != nil { - return err - } - if err := os.WriteFile(mobileOut, mpng, 0o644); err != nil { - return fmt.Errorf("write %s: %w", mobileOut, err) - } - fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng)) - } - return nil - }, - } - cmd.Flags().StringVar(&gallery, "gallery", "", "Gallery CMS site base URL (has this theme loaded + showcase seeded)") - cmd.Flags().StringVar(&slug, "slug", "/", "Showcase page slug to capture") - cmd.Flags().StringVar(&out, "out", "preview.png", "Output path for the desktop screenshot (git-tracked in the theme repo)") - cmd.Flags().StringVar(&mobileOut, "mobile-out", "preview-mobile.png", "Output path for the mobile screenshot") - cmd.Flags().StringVar(&themeName, "theme", "", "Theme key to preview (default: plugin.mod name)") - cmd.Flags().StringVar(&waitSelector, "wait", "section", "CSS selector to wait for before capturing") - cmd.Flags().BoolVar(&mobile, "mobile", false, "Also capture preview-mobile.png at a phone viewport") - cmd.Flags().IntVar(&width, "width", 1440, "Desktop viewport width") - cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height") - return cmd -} diff --git a/cmd/ninja/cmd/theme_test.go b/cmd/ninja/cmd/theme_test.go deleted file mode 100644 index 8859451..0000000 --- a/cmd/ninja/cmd/theme_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package cmd - -import ( - "strings" - "testing" -) - -func TestThemeScreenshotCommandRegistered(t *testing.T) { - root := NewRoot() - theme, _, err := root.Find([]string{"theme", "screenshot"}) - if err != nil { - t.Fatalf("find theme screenshot: %v", err) - } - if theme.Name() != "screenshot" { - t.Fatalf("resolved command = %q, want screenshot", theme.Name()) - } -} - -func TestThemeScreenshotHasGalleryAndMobileFlags(t *testing.T) { - root := NewRoot() - cmd, _, _ := root.Find([]string{"theme", "screenshot"}) - for _, name := range []string{"gallery", "mobile", "out", "slug"} { - if cmd.Flags().Lookup(name) == nil { - t.Errorf("missing --%s flag", name) - } - } -} - -func TestThemeScreenshotDefaultOutIsPreviewPng(t *testing.T) { - root := NewRoot() - cmd, _, _ := root.Find([]string{"theme", "screenshot"}) - out, _ := cmd.Flags().GetString("out") - if !strings.HasSuffix(out, "preview.png") { - t.Fatalf("default --out = %q, want it to end with preview.png", out) - } -} diff --git a/cmd/ninja/cmd/version.go b/cmd/ninja/cmd/version.go deleted file mode 100644 index a5501c9..0000000 --- a/cmd/ninja/cmd/version.go +++ /dev/null @@ -1,19 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var Version = "dev" - -func newVersionCmd() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print ninja version", - Run: func(_ *cobra.Command, _ []string) { - fmt.Println("ninja", Version) - }, - } -} diff --git a/cmd/ninja/internal/archive/archive.go b/cmd/ninja/internal/archive/archive.go deleted file mode 100644 index ceb5d45..0000000 --- a/cmd/ninja/internal/archive/archive.go +++ /dev/null @@ -1,56 +0,0 @@ -package archive - -import ( - "bytes" - "fmt" - "io" - "os/exec" - "strings" - - "github.com/klauspost/compress/zstd" -) - -// BuildSourceArchive captures the working tree as `tar.zst` bytes. -// -// When the working tree is clean it archives HEAD. When it's dirty -// (modified or staged tracked files), it archives a temporary stash -// object so the dirty state is what ships — callers that want -// HEAD-only behaviour should reject dirty trees before calling. -// Untracked files are never included regardless of state. -func BuildSourceArchive(repoDir string) ([]byte, error) { - stashCmd := exec.Command("git", "stash", "create") - stashCmd.Dir = repoDir - var stashOut, stashErr bytes.Buffer - stashCmd.Stdout = &stashOut - stashCmd.Stderr = &stashErr - if err := stashCmd.Run(); err != nil { - return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String()) - } - treeish := "HEAD" - if sha := strings.TrimSpace(stashOut.String()); sha != "" { - treeish = sha - } - - cmd := exec.Command("git", "archive", "--format=tar", treeish) - cmd.Dir = repoDir - var tarOut, stderr bytes.Buffer - cmd.Stdout = &tarOut - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("git archive: %v: %s", err, stderr.String()) - } - - var compressed bytes.Buffer - enc, err := zstd.NewWriter(&compressed, zstd.WithEncoderLevel(zstd.SpeedDefault)) - if err != nil { - return nil, err - } - if _, err := io.Copy(enc, &tarOut); err != nil { - _ = enc.Close() - return nil, err - } - if err := enc.Close(); err != nil { - return nil, err - } - return compressed.Bytes(), nil -} diff --git a/cmd/ninja/internal/archive/archive_test.go b/cmd/ninja/internal/archive/archive_test.go deleted file mode 100644 index 4454e78..0000000 --- a/cmd/ninja/internal/archive/archive_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package archive - -import ( - "archive/tar" - "bytes" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/klauspost/compress/zstd" -) - -func TestBuildSourceArchive_RoundTrip(t *testing.T) { - dir := t.TempDir() - run := func(name string, args ...string) { - t.Helper() - cmd := exec.Command(name, args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=t", - "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", - "GIT_COMMITTER_EMAIL=t@t", - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("%s %v: %v\n%s", name, args, err, out) - } - } - run("git", "init", "-q") - if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), - []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("nope"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.log\n"), 0o644); err != nil { - t.Fatal(err) - } - run("git", "add", "plugin.mod", ".gitignore") - run("git", "commit", "-qm", "init") - - zstdBytes, err := BuildSourceArchive(dir) - if err != nil { - t.Fatalf("BuildSourceArchive: %v", err) - } - if len(zstdBytes) == 0 { - t.Fatal("empty archive") - } - - dec, err := zstd.NewReader(bytes.NewReader(zstdBytes)) - if err != nil { - t.Fatal(err) - } - defer dec.Close() - tr := tar.NewReader(dec) - got := map[string]string{} - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - buf, err := io.ReadAll(tr) - if err != nil { - t.Fatal(err) - } - got[hdr.Name] = string(buf) - } - if _, ok := got["plugin.mod"]; !ok { - t.Errorf("expected plugin.mod in archive, got %v", keys(got)) - } - if _, ok := got["ignored.log"]; ok { - t.Errorf("ignored.log should not be in archive (gitignored + untracked)") - } -} - -func keys(m map[string]string) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - return out -} - -func TestBuildSourceArchive_DirtyTreeShipsWorkingCopy(t *testing.T) { - dir := t.TempDir() - runGitArchive(t, dir, "init", "-q") - modPath := filepath.Join(dir, "plugin.mod") - if err := os.WriteFile(modPath, - []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - runGitArchive(t, dir, "add", "plugin.mod") - runGitArchive(t, dir, "commit", "-qm", "init") - - dirtyContents := []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n") - if err := os.WriteFile(modPath, dirtyContents, 0o644); err != nil { - t.Fatal(err) - } - - zstdBytes, err := BuildSourceArchive(dir) - if err != nil { - t.Fatalf("BuildSourceArchive: %v", err) - } - - got := readArchive(t, zstdBytes) - contents, ok := got["plugin.mod"] - if !ok { - t.Fatalf("expected plugin.mod in archive, got %v", keys(got)) - } - if !strings.Contains(contents, `version="0.1.1"`) { - t.Errorf("archived plugin.mod should have dirty version 0.1.1, got: %q", contents) - } - if strings.Contains(contents, `version="0.1.0"`) { - t.Errorf("archived plugin.mod should NOT have HEAD version 0.1.0, got: %q", contents) - } - - // Working tree should be unchanged after stash-create. - postContents, err := os.ReadFile(modPath) - if err != nil { - t.Fatal(err) - } - if string(postContents) != string(dirtyContents) { - t.Errorf("working tree mutated after BuildSourceArchive\nwant: %q\ngot: %q", - string(dirtyContents), string(postContents)) - } -} - -func TestBuildSourceArchive_DirtyTreeOmitsUntracked(t *testing.T) { - dir := t.TempDir() - runGitArchive(t, dir, "init", "-q") - modPath := filepath.Join(dir, "plugin.mod") - if err := os.WriteFile(modPath, - []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil { - t.Fatal(err) - } - runGitArchive(t, dir, "add", "plugin.mod") - runGitArchive(t, dir, "commit", "-qm", "init") - - // Dirty the tracked file. - if err := os.WriteFile(modPath, - []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n"), 0o644); err != nil { - t.Fatal(err) - } - // Add an untracked file (no git add). - if err := os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("not tracked"), 0o644); err != nil { - t.Fatal(err) - } - - zstdBytes, err := BuildSourceArchive(dir) - if err != nil { - t.Fatalf("BuildSourceArchive: %v", err) - } - - got := readArchive(t, zstdBytes) - if _, ok := got["extra.txt"]; ok { - t.Errorf("untracked extra.txt should not be in archive, got %v", keys(got)) - } -} - -func runGitArchive(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=t", - "GIT_AUTHOR_EMAIL=t@t", - "GIT_COMMITTER_NAME=t", - "GIT_COMMITTER_EMAIL=t@t", - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v\n%s", args, err, out) - } -} - -func readArchive(t *testing.T, zstdBytes []byte) map[string]string { - t.Helper() - dec, err := zstd.NewReader(bytes.NewReader(zstdBytes)) - if err != nil { - t.Fatal(err) - } - defer dec.Close() - tr := tar.NewReader(dec) - got := map[string]string{} - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - buf, err := io.ReadAll(tr) - if err != nil { - t.Fatal(err) - } - got[hdr.Name] = string(buf) - } - return got -} diff --git a/cmd/ninja/internal/bnp/build.go b/cmd/ninja/internal/bnp/build.go deleted file mode 100644 index 1db9b45..0000000 --- a/cmd/ninja/internal/bnp/build.go +++ /dev/null @@ -1,279 +0,0 @@ -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 -} diff --git a/cmd/ninja/internal/bnp/codeless.go b/cmd/ninja/internal/bnp/codeless.go deleted file mode 100644 index 31ba90c..0000000 --- a/cmd/ninja/internal/bnp/codeless.go +++ /dev/null @@ -1,515 +0,0 @@ -package bnp - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" - core "git.dev.alexdunmow.com/block/core/plugin" - "google.golang.org/protobuf/proto" - "gopkg.in/yaml.v3" -) - -// Codeless artifacts (WO-WZ-020): a .bnp with NO plugin.wasm — pure -// declaration the host runs. Classification is by repo shape: a plugin repo -// with no Go source builds codeless; a repo with Go builds wasm as always -// (a converted repo DELETES its Go — that's the point). Both artifact kinds -// may carry blocks/, templates/, and seed/; codeless just has nothing else. - -// Declarative artifact dirs shared by both build paths. -const ( - dirBlocks = "blocks" - dirTemplates = "templates" - dirSeed = "seed" -) - -// manifestYAMLName is the optional root-level declarative manifest source for -// codeless plugins (the counterpart of what DESCRIBE captures from Go code). -const manifestYAMLName = "manifest.yaml" - -// manifestYAML mirrors the declarative PluginManifest fields a codeless -// plugin can set. File-valued keys are paths relative to the repo root. -type manifestYAML struct { - ThemePresets string `yaml:"theme_presets"` // JSON file - BundledFonts string `yaml:"bundled_fonts"` // JSON file - SettingsSchema string `yaml:"settings_schema"` // JSON file - MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON) - RequiredIconPacks []string `yaml:"required_icon_packs"` - SystemTemplates []struct { - Key string `yaml:"key"` - Title string `yaml:"title"` - Description string `yaml:"description"` - } `yaml:"system_templates"` - PageTemplates []struct { - System string `yaml:"system"` - Key string `yaml:"key"` - Title string `yaml:"title"` - Description string `yaml:"description"` - Slots []string `yaml:"slots"` - } `yaml:"page_templates"` - // TemplateOverrides declare theme-scoped overrides of builtin block - // rendering. Source convention: templates/overrides/