cli/cmd/ninja/cmd/plugin.go
Alex Dunmow b5e39121d2 feat(cli): ninja plugin gallery commands (Phase 6)
Add `ninja plugin gallery list|add|remove|reorder|set-featured` for
author-side curation of a plugin/theme's registry screenshot gallery,
feeding the browse/detail surfaces shipped in Phases 4-5.

- Re-vendor plugin_registry.proto from orchestrator (adds
  PluginGalleryService) and regenerate the connect client.
- Wire the Gallery client into orchclient.Client.
- Target plugin defaults from plugin.mod with --scope/--name overrides;
  device-flow auth via resolveClient. list is a public read; add/remove/
  reorder/set-featured are RoleUser (scope/account membership enforced
  server-side). add derives content_type from the extension against the
  png/jpg/jpeg/webp allowlist and rejects >8MiB client-side.

Scope handling trims a leading @ client-side because ListScreenshots does
not trim server-side (GetPlugin does). Live-verified against the dev
orchestrator; go build + go test ./... + check-safety all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:35:09 +08:00

1178 lines
36 KiB
Go

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/cli/internal/bnp"
"git.dev.alexdunmow.com/block/cli/internal/creds"
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/cli/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(),
newPluginGalleryCmd(),
)
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 <slug>`")
}
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 <slug>`")
}
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/<name>, 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 <slug>`)")
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 <slug>`")
}
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: "Build the .bnp artifact and publish it to the registry",
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 <slug>`")
}
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
}
// A .bnp artifact is the ONLY thing publish ships — instances
// reject source archives at install time, so the legacy
// source-archive path is gone. Default: build the artifact right
// here (same pipeline as `ninja plugin build`). --bnp skips the
// build and uploads a prebuilt artifact instead. The orchestrator
// verifies the .bnp layout/ABI/name/version server-side and
// records its abi_version.
var archiveBytes []byte
if bnpFile != "" {
archiveBytes, err = os.ReadFile(bnpFile)
if err != nil {
return fmt.Errorf("read --bnp file: %w", err)
}
} else {
isCodeless, err := bnp.IsCodelessRepo(".")
if err != nil {
return err
}
var res *bnp.BuildResult
if isCodeless {
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: "."})
} else {
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: "."})
}
if err != nil {
return fmt.Errorf("build .bnp: %w", err)
}
printBuildSummary(res)
// Mirror the orchestrator's verify gate (manifest version must
// match the published version when set) so the drift fails
// here, before the upload, with a fixable message.
if res.Version != "" && res.Version != mod.Plugin.Version {
return fmt.Errorf("manifest version %s != plugin.mod version %s (sync Registration.Version with plugin.mod)", res.Version, mod.Plugin.Version)
}
archiveBytes, err = os.ReadFile(res.OutputPath)
if err != nil {
return fmt.Errorf("read built artifact: %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 (.bnp artifact, %d bytes)\n", mod.Coords(), 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: warn and build from the working directory)")
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 artifact (from `ninja plugin build`) instead of building one")
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 (the auto-commit of plugin.mod and the dirty-tree check
// both need a HEAD to diff against).
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
}
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)
}
// emitPublishWarnings gates the publish on working-tree state. The .bnp is
// built from the working directory while the version tag and registry
// metadata point at commits — a dirty tree means the published artifact may
// not match the tagged source.
//
// - allowDirty=false (--strict): a dirty tree aborts the publish.
// - allowDirty=true (the default): a dirty tree emits a drift warning
// listing the changed/untracked paths, then the publish proceeds.
func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error {
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = repoDir
out, _ := cmd.Output()
dirty := strings.TrimSpace(string(out))
if dirty == "" {
return nil
}
if !allowDirty {
return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict")
}
_, _ = fmt.Fprintln(w, "warning: working tree differs from HEAD; the published .bnp is built from the working directory:")
for n := range strings.SplitSeq(dirty, "\n") {
_, _ = fmt.Fprintln(w, " "+n)
}
_, _ = fmt.Fprintln(w, " (commit before publishing so the artifact matches the tagged source)")
return nil
}
// 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
}