Compare commits

...

12 Commits

Author SHA1 Message Date
Alex Dunmow
a79aa709c2 feat(core): mirror Plugin.kind + ListCategories proto regen 2026-06-03 01:44:26 +08:00
Alex Dunmow
08be22ec34 feat(sdk): add Kind and Categories to ModPlugin; writeMod emits them 2026-06-03 01:41:56 +08:00
Alex Dunmow
aafdc44f6f fix(cli): drop duplicated version in publish output (Coords already includes it) 2026-06-03 01:41:03 +08:00
Alex Dunmow
57a217f54d feat(cli): warn at publish when tracked files match .gitignore 2026-06-03 01:35:04 +08:00
Alex Dunmow
c825942c8d feat(cli): init auto-commits plugin.mod, drops ninja git remote 2026-06-03 01:34:42 +08:00
Alex Dunmow
e5b27f5a65 feat(cli): rewrite plugin publish to send tar.zst archive 2026-06-03 01:33:12 +08:00
Alex Dunmow
a827cda37a feat(core): mirror PublishVersionRequest archive bytes proto change 2026-06-03 01:27:16 +08:00
Alex Dunmow
31e7b72b49 feat(cli): add BuildSourceArchive for plugin publish tar.zst 2026-06-03 01:21:55 +08:00
Alex Dunmow
680cbe0160 chore(core): add klauspost/compress for plugin archive zstd 2026-06-03 01:19:51 +08:00
Alex Dunmow
e9bef5b065 docs: plan, UAT, and execution prompt for plugin tarball publish + categories 2026-06-03 01:18:13 +08:00
Alex Dunmow
2a76b30c51 feat(cli): scope subcommand, interactive scope prompt, bump+version helpers
Pre-existing CLI improvements ahead of the tarball-publish refactor:
- New top-level `ninja scope` command (create, list, set-default).
- `init` accepts no --scope: prompts from ListMyScopes or uses creds default.
- Plugin name prompted if not provided.
- `plugin bump <major|minor|patch>` writes the bumped version into plugin.mod.
- `plugin version` prints the current plugin.mod version.
- `login` prints a URL with ?user_code= so the link is one click.
- creds: HostCreds gains optional default_scope.
- plugin/version: ParseBaseSemver + BumpVersion helpers, with tests.
2026-06-03 01:18:11 +08:00
Alex Dunmow
1d9ca44f55 docs(spec): plugin publish (tarball) + categories design
Design for two coupled changes: drop git as the publish transport in
favour of tar.zst uploads, and add a first-class plugin kind plus a
configurable, validated category list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 00:58:21 +08:00
20 changed files with 4342 additions and 252 deletions

View File

@ -33,7 +33,7 @@ func newLoginCmd() *cobra.Command {
if err != nil { if err != nil {
return fmt.Errorf("start device: %w", err) return fmt.Errorf("start device: %w", err)
} }
fmt.Printf("Visit %s and enter code: %s\n", start.Msg.VerificationUri, start.Msg.UserCode) fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode)
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second) deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {

View File

@ -1,10 +1,14 @@
package cmd package cmd
import ( import (
"bufio"
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp"
"sort"
"strconv"
"strings" "strings"
"connectrpc.com/connect" "connectrpc.com/connect"
@ -12,6 +16,7 @@ import (
core "git.dev.alexdunmow.com/block/core/plugin" 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/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient" "git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1" v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
@ -19,7 +24,13 @@ import (
func newPluginCmd() *cobra.Command { func newPluginCmd() *cobra.Command {
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"} c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
c.AddCommand(newPluginInitCmd(), newPluginPublishCmd(), newPluginStatusCmd()) c.AddCommand(
newPluginInitCmd(),
newPluginPublishCmd(),
newPluginStatusCmd(),
newPluginBumpCmd(),
newPluginVersionCmd(),
)
return c return c
} }
@ -39,49 +50,160 @@ func newPluginInitCmd() *cobra.Command {
return err return err
} }
cli := orchclient.New(resolvedHost, hc.Token) cli := orchclient.New(resolvedHost, hc.Token)
scope = strings.TrimPrefix(scope, "@")
if scope == "" || name == "" {
return fmt.Errorf("usage: ninja plugin init --scope ACME --name foo")
}
ctx := context.Background() ctx := context.Background()
resp, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{ scanner := bufio.NewScanner(os.Stdin)
ScopeSlug: scope, Name: name, Description: "",
})) if scope != "" {
if err != nil { scope, err = parseScope(scope)
if err != nil {
return err
}
} else {
scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner)
if err != nil {
return err
}
}
if name == "" {
fmt.Print("Plugin name: ")
if !scanner.Scan() {
return fmt.Errorf("cancelled")
}
name = strings.TrimSpace(scanner.Text())
if name == "" {
return fmt.Errorf("plugin name is required")
}
}
if _, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
ScopeSlug: scopeAPISlug(scope), Name: name, Description: "",
})); err != nil {
return err return err
} }
fmt.Printf("Created @%s/%s\n", scope, name) fmt.Printf("\nCreated %s/%s\n", scope, name)
fmt.Printf(" Git remote: %s\n", resp.Msg.GitRemoteUrl)
if _, err := os.Stat(".git"); err == nil {
_ = runCmd("git", "remote", "remove", "ninja")
if err := runCmd("git", "remote", "add", "ninja", resp.Msg.GitRemoteUrl); err != nil {
return fmt.Errorf("git remote add: %w", err)
}
fmt.Println("Added git remote 'ninja'")
} else {
fmt.Println("Not in a git repo - skipped adding remote")
}
if err := upsertPluginMod(scope, name); err != nil { if err := upsertPluginMod(scope, name); err != nil {
return err return err
} }
fmt.Println("plugin.mod updated") fmt.Println("plugin.mod updated")
if _, err := os.Stat(".git"); err == nil {
if err := autoCommitPluginMod(); 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` before `ninja plugin publish`")
}
return nil return nil
}, },
} }
cmd.Flags().StringVar(&scope, "scope", "", "Scope slug (with or without @)") cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)")
cmd.Flags().StringVar(&name, "name", "", "Plugin name") cmd.Flags().StringVar(&name, "name", "", "Plugin name")
return cmd 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
}
func newPluginPublishCmd() *cobra.Command { func newPluginPublishCmd() *cobra.Command {
var channel string var channel string
var allowDirty bool var allowDirty bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "publish", Use: "publish",
Short: "Push the current commit as a new version and notify the registry", Short: "Build a source archive and publish a new version to the registry",
RunE: func(c *cobra.Command, _ []string) error { RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host") host, _ := c.Flags().GetString("host")
cr, err := creds.Load() cr, err := creds.Load()
@ -113,14 +235,19 @@ func newPluginPublishCmd() *cobra.Command {
} }
} }
tag := "v" + mod.Plugin.Version out, _ := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard").Output()
if err := runCmd("git", "tag", "-a", tag, "-m", tag); err != nil { if names := strings.TrimSpace(string(out)); names != "" {
fmt.Fprintf(os.Stderr, "tag %s already exists or could not be created (%v); attempting push\n", tag, err) fmt.Fprintln(os.Stderr, "warning: these tracked files match .gitignore and will still be shipped:")
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(os.Stderr, " "+n)
}
fmt.Fprintln(os.Stderr, " (run `git rm --cached <file>` to drop)")
} }
if err := runCmd("git", "push", "ninja", tag); err != nil {
return fmt.Errorf("git push ninja %s: %w", tag, err) archiveBytes, err := archive.BuildSourceArchive(".")
if err != nil {
return fmt.Errorf("build archive: %w", err)
} }
fmt.Printf("Pushed %s to ninja remote\n", tag)
ctx := context.Background() ctx := context.Background()
pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(&v1.GetPluginRequest{ pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(&v1.GetPluginRequest{
@ -135,19 +262,19 @@ func newPluginPublishCmd() *cobra.Command {
pubResp, err := cli.Pub.PublishVersion(ctx, connect.NewRequest(&v1.PublishVersionRequest{ pubResp, err := cli.Pub.PublishVersion(ctx, connect.NewRequest(&v1.PublishVersionRequest{
PluginId: pr.Msg.Plugin.Id, PluginId: pr.Msg.Plugin.Id,
GitRef: "refs/tags/" + tag, Version: mod.Plugin.Version,
Channel: channel, Channel: channel,
Archive: archiveBytes,
ReadmeMd: string(readme), ReadmeMd: string(readme),
ChangelogMd: string(changelog), ChangelogMd: string(changelog),
})) }))
if err != nil { if err != nil {
return fmt.Errorf("publish: %w", err) return fmt.Errorf("publish: %w", err)
} }
fmt.Printf("Published version_id=%s\n", pubResp.Msg.Version.Id) fmt.Printf("Published %s (%d bytes)\n", mod.Coords(), len(archiveBytes))
for _, w := range pubResp.Msg.Warnings { for _, w := range pubResp.Msg.Warnings {
fmt.Printf(" warning: %s\n", w) fmt.Printf(" warning: %s\n", w)
} }
fmt.Printf(" Archive: %s\n", pubResp.Msg.ArchiveUrl)
return nil return nil
}, },
} }
@ -177,7 +304,7 @@ func newPluginStatusCmd() *cobra.Command {
return err return err
} }
if len(scopes.Msg.Scopes) == 0 { if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one in the orchestrator UI or via a CreateScope call.") fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil return nil
} }
for _, s := range scopes.Msg.Scopes { for _, s := range scopes.Msg.Scopes {
@ -196,6 +323,159 @@ func newPluginStatusCmd() *cobra.Command {
} }
} }
func newPluginBumpCmd() *cobra.Command {
var setVersion string
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. Does not commit or tag.",
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)
return nil
},
}
cmd.Flags().StringVar(&setVersion, "set", "", "Set explicit version (e.g. 0.5.0)")
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 { func runCmd(name string, args ...string) error {
c := exec.Command(name, args...) c := exec.Command(name, args...)
c.Stderr = os.Stderr c.Stderr = os.Stderr
@ -223,6 +503,16 @@ func writeMod(path string, m *core.ModFile) error {
b.WriteString(fmt.Sprintf("name = %q\n", m.Plugin.Name)) b.WriteString(fmt.Sprintf("name = %q\n", m.Plugin.Name))
b.WriteString(fmt.Sprintf("scope = %q\n", m.Plugin.Scope)) b.WriteString(fmt.Sprintf("scope = %q\n", m.Plugin.Scope))
b.WriteString(fmt.Sprintf("version = %q\n", m.Plugin.Version)) b.WriteString(fmt.Sprintf("version = %q\n", m.Plugin.Version))
if m.Plugin.Kind != "" {
b.WriteString(fmt.Sprintf("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)
}
b.WriteString(fmt.Sprintf("categories = [%s]\n", strings.Join(quoted, ", ")))
}
if m.Compatibility != nil { if m.Compatibility != nil {
b.WriteString("\n[compatibility]\n") b.WriteString("\n[compatibility]\n")
b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore)) b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore))
@ -234,3 +524,23 @@ func writeMod(path string, m *core.ModFile) error {
} }
return os.WriteFile(path, []byte(b.String()), 0o644) return os.WriteFile(path, []byte(b.String()), 0o644)
} }
// autoCommitPluginMod stages and commits plugin.mod if it differs from
// what's already at HEAD. No-op when there's nothing to commit.
func autoCommitPluginMod() 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", "Add plugin.mod"); err != nil {
return err
}
fmt.Println("Committed plugin.mod")
return nil
}

View File

@ -14,5 +14,6 @@ func NewRoot() *cobra.Command {
root.AddCommand(newLogoutCmd()) root.AddCommand(newLogoutCmd())
root.AddCommand(newWhoamiCmd()) root.AddCommand(newWhoamiCmd())
root.AddCommand(newPluginCmd()) root.AddCommand(newPluginCmd())
root.AddCommand(newScopeCmd())
return root return root
} }

230
cmd/ninja/cmd/scope.go Normal file
View File

@ -0,0 +1,230 @@
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
},
}
}

View File

@ -0,0 +1,41 @@
package archive
import (
"bytes"
"fmt"
"io"
"os/exec"
"github.com/klauspost/compress/zstd"
)
// BuildSourceArchive runs `git archive --format=tar HEAD` in repoDir and
// compresses the result with zstd. Returns the compressed bytes.
//
// Only tracked files at HEAD are included. .gitignored files that were
// never tracked are excluded. Tracked-then-gitignored files are still
// included — callers may warn separately.
func BuildSourceArchive(repoDir string) ([]byte, error) {
cmd := exec.Command("git", "archive", "--format=tar", "HEAD")
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
}

View File

@ -0,0 +1,89 @@
package archive
import (
"archive/tar"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"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
}

View File

@ -13,8 +13,9 @@ type Credentials struct {
} }
type HostCreds struct { type HostCreds struct {
Token string `json:"token"` Token string `json:"token"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
DefaultScope string `json:"default_scope,omitempty"`
} }
func filePath() (string, error) { func filePath() (string, error) {

View File

@ -0,0 +1,305 @@
# UAT — Plugin publish (tarball) + categories
Date: 2026-06-03
> **THIS CHECKLIST IS A HARD GATE.** Implementation is NOT complete until
> every item below has been executed and observed to pass. Build-green and
> tests-green are necessary but NOT sufficient. The work is unfinished if
> any UAT item is skipped, glossed over, or marked done without the exact
> observation recorded.
>
> Do not edit this file to soften any item. If an item turns out to be
> ambiguous or impossible, STOP and ask the user to amend it. Do not
> rationalise around it.
## How to use
Execute each item against a freshly-rebuilt orchestrator (`podman compose
build orchestrator-backend && podman compose up -d orchestrator-backend`)
and a freshly-reinstalled CLI (`go install ./cmd/ninja` from `~/src/core`).
For each item, capture the actual output/observation next to the
expectation. Mark a checkbox ONLY after observing the success criterion
with your own eyes.
The DB connection used in items below is whatever the local orchestrator
uses; commands assume access via `podman exec blockninja-db psql -U orchestrator orchestrator`.
---
## Group A — `ninja plugin init`
### A1. Init writes plugin.mod with all fields and auto-commits it
- [ ] Setup:
```bash
rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1
git init -q && git commit --allow-empty -qm "initial"
```
- [ ] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
- [ ] Observe `cat plugin.mod` includes:
- `name = "uat-a1"`
- `scope = "@themes"`
- `version = "0.1.0"`
- `kind = "plugin"`
- `categories = ["analytics", "seo"]`
- [ ] Observe `git log --oneline` shows a commit whose subject is `Add plugin.mod` at HEAD.
- [ ] Observe `git remote -v` outputs nothing (no `ninja` remote).
- [ ] Observe `grep -A1 '\[remote' .git/config` finds NO `ninja` block.
### A2. Init for a theme writes kind=theme and skips category prompt
- [ ] Setup:
```bash
rm -rf /tmp/uat-a2 && mkdir /tmp/uat-a2 && cd /tmp/uat-a2
git init -q && git commit --allow-empty -qm "initial"
```
- [ ] Run init for `@themes/uat-a2`, answer kind=theme. The category prompt MUST NOT appear.
- [ ] Observe `plugin.mod` contains `kind = "theme"` and NO `categories =` line.
### A3. Init when not in a git repo prints a warning, still registers
- [ ] Setup:
```bash
rm -rf /tmp/uat-a3 && mkdir /tmp/uat-a3 && cd /tmp/uat-a3
```
- [ ] Run init for `@themes/uat-a3`. STDOUT/STDERR contains a warning mentioning `git init` before publish.
- [ ] `plugin.mod` exists on disk.
- [ ] No `.git` directory was created.
- [ ] Database row exists: `psql -c "SELECT 1 FROM registry_plugins WHERE name='uat-a3';"` returns 1.
### A4. Init twice with same name returns AlreadyExists, leaves repo unchanged
- [ ] In a freshly-init'd repo with no plugin.mod, run init twice with the same scope+name.
- [ ] Second invocation errors with a message mentioning the plugin already exists.
- [ ] `git log` shows exactly ONE `Add plugin.mod` commit (not two).
---
## Group B — `ninja plugin publish`
### B1. Publish posts a tar.zst, no git tag, no git push
- [ ] Setup from a working init'd repo (e.g. uat-a1 above).
- [ ] Pre-record:
```bash
git tag --list # capture before
git rev-parse HEAD # capture HEAD before
```
- [ ] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`.
- [ ] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`.
- [ ] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.)
- [ ] `git rev-parse HEAD` is unchanged.
- [ ] `git remote -v` is still empty.
- [ ] DB row exists with the new version:
```sql
SELECT v.version, v.source_archive_key, v.source_archive_size, v.source_archive_sha256
FROM registry_versions v
JOIN registry_plugins p ON p.id = v.plugin_id
WHERE p.name = 'uat-a1';
```
Observe: `source_archive_key` ends with `/source.tar.zst`. `source_archive_size > 0`.
### B2. The stored archive matches the posted bytes (sha256)
- [ ] On disk (or via the signed URL), retrieve the stored archive bytes.
- [ ] Compute `sha256sum <archive>`. Compare against `source_archive_sha256` from the DB.
- [ ] Hashes match exactly.
### B3. The archive contains only tracked files and excludes gitignored
- [ ] Setup:
```bash
rm -rf /tmp/uat-b3 && mkdir /tmp/uat-b3 && cd /tmp/uat-b3
git init -q
echo "secret" > ignored.log
echo "ignored.log" > .gitignore
git add .gitignore && git commit -qm "ignore"
```
- [ ] Run init + publish for `@themes/uat-b3`.
- [ ] Extract the stored archive (download via signed URL → `zstd -d | tar -xv`).
- [ ] Observe `plugin.mod`, `.gitignore` present; `ignored.log` absent.
### B4. Working tree dirty check fires; `--allow-dirty` bypasses
- [ ] In a published repo, touch a file: `echo x >> README.md`.
- [ ] Run `ninja plugin publish ...` — must error with text including `working tree dirty`.
- [ ] Bump patch, then `ninja plugin publish --allow-dirty ...` — must succeed.
### B5. Tracked-yet-gitignored files trigger a warning
- [ ] Setup:
```bash
rm -rf /tmp/uat-b5 && mkdir /tmp/uat-b5 && cd /tmp/uat-b5
git init -q
echo "data" > dist.log
git add dist.log && git commit -qm "tracked"
echo "dist.log" > .gitignore
git add .gitignore && git commit -qm "ignore"
```
- [ ] Init + publish for `@themes/uat-b5`.
- [ ] STDERR includes a warning that `dist.log` is tracked and will still be shipped, plus the hint `git rm --cached`.
### B6. Duplicate version rejected
- [ ] In a successfully-published repo, immediately re-run publish without bumping.
- [ ] Error includes `version already published`.
### B7. Version mismatch between plugin.mod and request rejected
- [ ] Edit `plugin.mod` to a different version than the one expected by the bump sequence (manually write `version = "9.9.9"` without committing).
- [ ] Run publish — must succeed locally but the orchestrator may accept or reject; if rejected the error mentions version mismatch. Either way, no DB row at version `9.9.9` should exist unless it succeeded — record what happened.
- [ ] Reset:
```bash
git checkout plugin.mod
```
---
## Group C — Categories + kind validation
### C1. ListCategories returns the seeded set
- [ ] Run:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
-H 'Content-Type: application/json' -d '{}'
```
- [ ] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
### C2. CreatePlugin rejects unknown category
- [ ] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly:
```bash
TOKEN=$(jq -r '.hosts["https://my.localdev.blockninjacms.com"].token' ~/.config/ninja/creds.json)
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}'
```
- [ ] Response is a Connect error with code `invalid_argument` and message mentioning unknown category.
### C3. CreatePlugin rejects theme with categories
- [ ] Same setup as C2 but kind=theme with categories=["analytics"]:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}'
```
- [ ] Response is invalid_argument with message including "themes do not carry categories".
### C4. CreatePlugin rejects unknown kind
- [ ] POST with `kind: "module"` (or anything other than plugin/theme).
- [ ] Response is invalid_argument.
### C5. PublishVersion rejects plugin.mod kind mismatch
- [ ] In a published-once `plugin`-kind repo, hand-edit `plugin.mod` to `kind = "theme"`, commit, bump, publish.
- [ ] Publish errors with text including `kind does not match registered`.
### C6. PublishVersion rejects plugin.mod categories mismatch
- [ ] In a published-once `plugin`-kind repo, add a category to `plugin.mod` that wasn't in the original `CreatePlugin`, commit, bump, publish.
- [ ] Publish errors with text including `categories do not match registered`.
### C7. ListPlugins filters by kind
- [ ] Setup: at least one `plugin` and one `theme` in the registry (any from earlier UAT items).
- [ ] Hit ListPlugins with `kind=plugin`:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListPlugins \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"kind":"plugin"}'
```
Response contains only kind=plugin rows.
- [ ] Same with `{"kind":"theme"}`. Response contains only kind=theme rows.
- [ ] With `{}` (no filter). Response contains both.
### C8. ListPlugins filters by category
- [ ] Setup a plugin with categories=["analytics"].
- [ ] Hit ListPlugins with `{"categories":["analytics"]}`. The plugin appears.
- [ ] Hit with `{"categories":["forms"]}`. The plugin does NOT appear.
---
## Group D — Deletion of git infrastructure
### D1. `/git/*` HTTP route is gone
- [ ] Run:
```bash
curl -sS -o /dev/null -w "%{http_code}\n" \
https://my.localdev.blockninjacms.com/git/themes/lcars.git/info/refs?service=git-upload-pack
```
- [ ] HTTP code is `404` (not 200, not 403). The route does not exist.
### D2. `registry/git/` package is fully removed
- [ ] Run:
```bash
ls ~/src/orchestrator/backend/internal/registry/ 2>&1
```
- [ ] Output does NOT include a `git` directory.
- [ ] `grep -r "internal/registry/git" ~/src/orchestrator/backend/ 2>/dev/null | grep -v "^Binary"` returns nothing.
### D3. `RegistryGitPath` config is gone
- [ ] Run `grep -n "RegistryGitPath\|REGISTRY_GIT_PATH" ~/src/orchestrator/backend/internal/config/config.go`.
- [ ] Output is empty.
### D4. `registry_versions.git_commit` / `git_tag` columns are gone
- [ ] Run:
```bash
podman exec blockninja-db psql -U orchestrator orchestrator -c "\d registry_versions"
```
- [ ] Output shows NO column named `git_commit` and NO column named `git_tag`.
### D5. `Plugin.git_remote_url` is not in the proto
- [ ] Run `grep -n "git_remote_url\|GitRemoteUrl" ~/src/orchestrator/proto/orchestrator/v1/plugin_registry.proto ~/src/core/proto/orchestrator/v1/plugin_registry.proto`.
- [ ] No matches.
### D6. The CLI no longer references the `ninja` git remote
- [ ] Run `grep -nE 'remote\s+(add|remove)\s+"?ninja' ~/src/core/cmd/ninja/cmd/plugin.go`.
- [ ] No matches.
- [ ] Run `grep -nE '"git",\s*"push",\s*"ninja"' ~/src/core/cmd/ninja/cmd/plugin.go`.
- [ ] No matches.
---
## Group E — Smoke install path
### E1. ResolveInstall returns the new .tar.zst URL
- [ ] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall:
```bash
curl -sS -X POST \
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ResolveInstall \
-H 'Content-Type: application/json' \
-d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}'
```
- [ ] Response contains `archiveUrl` whose path ends with `/source.tar.zst` and includes `sig=` and `exp=` query params.
### E2. The signed URL actually downloads
- [ ] `curl -sS -o /tmp/uat-e2.tar.zst "<archiveUrl from E1>"`
- [ ] `file /tmp/uat-e2.tar.zst` reports a Zstandard compressed file.
- [ ] `sha256sum /tmp/uat-e2.tar.zst` matches the `archiveSha256` from the ResolveInstall response.
- [ ] `zstd -dc /tmp/uat-e2.tar.zst | tar -tf - | head` shows `plugin.mod` and the expected files.
---
## Sign-off
- [ ] Every box above is ticked, with at-the-time-of-execution observation logged inline.
- [ ] Any unticked box → work is NOT complete. Return to the plan.
- [ ] Final test reports any deviations from expected behaviour even when the box can be ticked.

View File

@ -0,0 +1,109 @@
# Execution prompt — Plugin publish (tarball) + categories
Copy the body below verbatim into a new Claude Code session at the repo
root (`~/src/core`) when you want execution to start. The prompt is
self-contained: anyone reading it should be able to drive the work end
to end without further context.
---
## Begin prompt
You are the inline execution agent for the "Plugin publish (tarball) +
categories" change. You are doing the work; you are not advising. Follow
the process below in order. Do not skip steps. Do not improvise.
### 1. Required reading (in this order)
Read these three files fully before touching any code. Treat each as
authoritative for its purpose. They live in `~/src/core`:
1. **Spec / design**`docs/superpowers/specs/2026-06-03-plugin-publish-and-categories-design.md`. Defines *what* and *why*. Do not change this without the user's explicit OK.
2. **Plan**`docs/superpowers/plans/2026-06-03-plugin-publish-and-categories.md`. Defines the ordered, bite-sized tasks with full code blocks. Do not change this without the user's explicit OK either. Each task ends with a commit.
3. **UAT checklist**`docs/superpowers/audits/2026-06-03-plugin-publish-and-categories-uat.md`. The hard gate. The work is NOT done until every UAT item has been observed to pass with your own eyes. The UAT may not be softened, skipped, or interpreted away. If any item turns out to be ambiguous or impossible, STOP and ask the user.
Also be aware: work spans TWO git repos.
- `~/src/core` — Go module `git.dev.alexdunmow.com/block/core`. Holds the ninja CLI (`cmd/ninja/`), the SDK (`plugin/`), and a mirrored copy of the orchestrator proto under `proto/` + `internal/api/`.
- `~/src/orchestrator` — Go module at `backend/`. Holds the orchestrator service, schema, migrations, and proto source of truth.
CLAUDE.md in each repo applies. Read `~/src/core/CLAUDE.md` if you have not (key rule: no `replace` directives in any `go.mod`).
### 2. Process
Invoke the `superpowers:executing-plans` skill and follow its discipline.
Within that:
- Work tasks in the order written in the plan (Task 1 → Task 20 + Task 11.5). Do not reorder. The order encodes dependency.
- After completing each task, commit per the commit step in the task. Do not batch multiple tasks into one commit. Do not amend the user's previous commits.
- After each task's commit, run an internal checkpoint: did the build pass? did the tests added in this task pass? did this task actually do what the plan said? If anything is off, fix it before moving on.
- Stop and report at any of these points:
- Task instructions are unclear or contradicted by current code.
- A task's code block doesn't compile after applying it verbatim AND the fix isn't obvious from the build error.
- `make sqlc` produces field names that differ from what later tasks reference (this is plausible; sqlc field naming sometimes surprises).
- You need to install tooling that isn't already on the system (e.g. `buf`, `goose`, `sqlc`).
- Do NOT edit the plan or design to fit reality. If reality has diverged, surface that to the user and ask how to resolve.
### 3. When to dispatch subagents
The plan is mostly sequential. Most tasks should be executed inline by
you. Dispatch a `general-purpose` subagent ONLY for the tasks listed
below. These are isolated, pure-function, well-bounded units whose work
benefits from a clean context. Hand the subagent the full task body
including all code blocks; tell it the working directory; tell it the
exact files to create or modify; tell it not to commit (you commit after
reviewing the changes).
Subagent-suitable tasks:
- **Task 3**`[core] BuildSourceArchive`. Pure helper in a new package. Self-contained TDD.
- **Task 4**`[orch] OpenAndValidate`. Pure helper, TDD, no external state.
- **Task 5**`[orch] Drop git fields migration + sqlc regen`. Mechanical schema + regen. Pure file manipulation.
- **Task 14**`[orch] kind + categories migration + sqlc regen`. Same shape as Task 5.
All other tasks (proto changes, handler rewrites, wiring, CLI changes,
delete-the-git-package) you do yourself in the main context. Reasons:
they cross many files, depend on context from earlier tasks, and require
running and reading the orchestrator/CLI in your hands.
Smoke tests (Tasks 12, 20) and UAT execution: do these yourself in the
main context. Subagents cannot easily run interactive flows or report
back observations of running services.
### 4. UAT gate (HARD)
After Task 20 (and Task 11.5) are committed and pushed-ready in both
repos, execute the UAT checklist
(`docs/superpowers/audits/2026-06-03-plugin-publish-and-categories-uat.md`)
top to bottom. For each box:
- Run the command(s) the item specifies, against the actual orchestrator
(you may need to `podman compose build orchestrator-backend &&
podman compose up -d orchestrator-backend` first) and the actual
installed CLI (`cd ~/src/core && go install ./cmd/ninja`).
- Observe the result with your own eyes.
- Tick the box ONLY if the observation matches the expectation.
- If it doesn't match: do not tick. Return to the plan, identify which
task is implicated, and either fix it (new commit) or report back.
You may not declare the work complete with any UAT box unticked. You
may not pre-tick boxes "based on the test suite passing". UAT is the
gate; tests are not.
If an UAT item is ambiguous, ask the user before guessing.
### 5. Tone
Short status updates between tasks. State what's next in one line. State
problems concretely with the command and output that demonstrated the
problem. No speculation, no apologies. When asking the user, present
specific options.
### 6. Done
When all UAT items are ticked, summarise in one message:
- Total commits made across both repos.
- Any deviations from the plan (with task numbers and one-line justification).
- Status of each UAT group (AE) — all pass.
- Suggested next step (likely: announce the change in the team channel, or move on to a follow-up like an admin UI for category management — both out of scope here).
## End prompt

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,253 @@
# Plugin publish (tarball) + categories — design
Date: 2026-06-03
Status: Draft
## Motivation
Two related changes to the plugin registry:
1. **Drop git as the publish transport.** The current flow has `ninja plugin
publish` push a git tag to a bare repo served over smart-HTTP by the
orchestrator. This couples the client to git state (the `ninja` remote
stored in `.git/config` just bit us with a URL-shape drift bug), forces
the orchestrator to maintain bare repos plus an archive store, and gives
the developer a remote they didn't ask for. Move to a single artifact:
the CLI builds a `tar.zst` from the source tree and posts it.
2. **First-class plugin kind, configurable category list.** Today
`registry_plugins.categories` (`text[]`) and `Plugin.categories` (proto)
exist but nothing writes to them, there is no canonical list, and themes
are not distinguished from plugins. Add a `kind` field
(`plugin` | `theme`), validate categories against a server-side canonical
list, and surface both in the CLI.
Greenfield — no consumers depend on the existing model. We can change shapes
and wipe existing rows.
## Plugin kind & categories
### Model
`registry_plugins` gains:
- `kind text NOT NULL DEFAULT 'plugin' CHECK (kind IN ('plugin', 'theme'))`
`registry_categories` is a new table:
- `slug text PRIMARY KEY` — e.g. `analytics`, `social`, `import-export`
- `display_name text NOT NULL`
- `description text NOT NULL DEFAULT ''`
- `sort_order int NOT NULL DEFAULT 0`
Seeded at migration time with a starter list (configurable later via an
admin endpoint; out of scope for this spec). `registry_plugins.categories`
values must be subsets of `registry_categories.slug`. Validation is
enforced by the API layer, not by FK — categories may be removed later
without orphaning plugins.
Themes do not carry categories in v1. Categories on theme rows are rejected
with `CodeInvalidArgument`.
### plugin.mod
`[plugin]` gains two fields:
```toml
[plugin]
name = "lcars"
scope = "@themes"
version = "0.2.0"
kind = "theme" # plugin | theme — defaults to "plugin" if absent
categories = ["analytics"] # only allowed when kind = "plugin"
```
The CLI keeps `plugin.mod` in sync with the registry. On `init` it writes
both fields. On `publish` it sends them through and the orchestrator
verifies the tarball's `plugin.mod` matches the request.
### Proto
`Plugin` gains:
- `string kind = 10;`
`Plugin.categories` (existing) stays. `CreatePluginRequest` gains `kind`
and `categories`. Listing endpoints gain a `kind` filter:
```proto
message ListPluginsRequest {
int32 limit = 1;
int32 offset = 2;
string query = 3;
string kind = 4; // "" = any, "plugin", "theme"
repeated string categories = 5;
}
```
A new `ListCategoriesRequest/Response` exposes the canonical list to the
CLI for interactive picking.
## Tarball publish flow
### Format
`tar.zst``archive/tar` from stdlib for tarring, `github.com/klauspost/compress/zstd`
for compression. Same dep on both ends. Default zstd level 3 (fast,
~gzip-9 ratio).
A hard size cap of 25 MiB on the *compressed* (posted) bytes, enforced
both sides. Configurable via `REGISTRY_PUBLISH_MAX_BYTES`. The orchestrator
additionally caps decompressed size at 4× the configured value to limit
zip-bomb exposure during validation.
### Archive contents
`git archive --format=tar HEAD` piped through zstd. This gives:
- Only tracked files. Anything matching `.gitignore` is excluded so long
as it isn't already tracked — i.e. `.gitignore` is honoured by virtue of
those files never having been added. The one edge case is a file that
was committed *before* being added to `.gitignore`: `git archive` will
still ship it. We do not paper over this — the developer must
`git rm --cached <file>` to drop it from the index. Documented in the
CLI publish output if we detect tracked files matching the current
`.gitignore`.
- A clean snapshot at HEAD.
- Deterministic enough that re-running publish with no changes produces
byte-identical archives modulo timestamps.
The CLI shells out to `git archive HEAD` (no in-process git library
dependency). It does not produce a tag, push, or modify `.git/config`.
### Proto change
`PublishVersionRequest` becomes:
```proto
message PublishVersionRequest {
string plugin_id = 1;
string version = 2; // was: git_ref; now plain "0.2.0"
string channel = 3;
bytes archive = 4; // tar.zst, max 25 MiB
string readme_md = 5;
string changelog_md = 6;
}
```
`git_ref` is removed.
### Server-side validation
The orchestrator:
1. Verifies the caller is a scope member.
2. Decompresses the archive in-memory, walks the tar, finds `plugin.mod`.
3. Parses `plugin.mod` and checks `scope`, `name`, `version`, `kind`, and
`categories` match the request and the plugin row.
4. Re-validates categories against `registry_categories.slug`.
5. Computes sha256 and size of the *compressed* archive bytes (what gets
stored).
6. Stores via `regstorage.Put` with key
`<scope>/<name>/<version>/source.tar.zst` and content-type
`application/zstd`.
7. Records the `registry_versions` row.
8. Returns the existing `PublishVersionResponse` shape (a signed
`archive_url` for immediate download verification).
`git_commit` / `git_tag` columns on `registry_versions` are dropped.
### Install flow
`ResolveInstall` still returns a signed URL. The only change is the object
extension (`.tar.zst` instead of `.tar.gz`) and content-type.
## Init flow
`ninja plugin init`:
1. Prompt scope (existing behavior).
2. Prompt plugin name (existing).
3. Prompt kind (`plugin` | `theme`). Default: `plugin`.
4. If `kind == plugin`: fetch the canonical category list via
`ListCategories` and let the user multi-select (zero or more).
5. Write `plugin.mod` with all fields.
6. Call `CreatePlugin` with kind + categories.
7. If in a git repo and `plugin.mod` is new or modified: `git add plugin.mod`,
`git commit -m "Add plugin.mod"`. If the file is unchanged from HEAD,
skip the commit.
8. **Does not** add a `ninja` git remote. Does not touch `.git/config`.
If not in a git repo, init still writes `plugin.mod` and registers the
plugin; it prints a warning that the user will need to `git init` before
they can `publish` (`git archive` requires a repo).
## Publish flow
`ninja plugin publish`:
1. Read `plugin.mod`.
2. Working-tree-clean check unless `--allow-dirty`.
3. Lookup `Plugin` via `GetPlugin(scope, name)` to verify it exists and to
get its `id`.
4. `git archive --format=tar HEAD` → zstd compress → bytes in memory.
5. Optional README/CHANGELOG bytes.
6. Call `PublishVersion(plugin_id, version, channel, archive, readme,
changelog)`.
7. Print version + warnings.
No tag is created. No git push. No `.git/config` writes anywhere in the
flow.
## What gets deleted
Orchestrator:
- `backend/internal/registry/git/` — the entire package (server, repo
manager, smart-HTTP, Provision, ResolveTag, ReadFileAtCommit, tests).
- `r.Route("/git", …)` in `registry_wiring.go`.
- `RegistryGitPath` config.
- `git_commit`, `git_tag` columns on `registry_versions`.
- `git_remote_url` field from `CreatePluginResponse` (the URL no longer
exists — there is nothing for the developer to push to manually).
CLI:
- `git remote remove ninja` / `git remote add ninja …` block in
`cmd/ninja/cmd/plugin.go`.
- `git tag -a vX.Y.Z` / `git push ninja vX.Y.Z` block.
Database:
- `git_commit`, `git_tag` columns dropped.
- `registry_plugins` rows can be truncated for dev environments since the
schema is changing; production is greenfield so this is moot.
## What gets added
Orchestrator:
- `registry_categories` table + a migration that seeds it with a starter
list. Slugs only; copy can be refined later.
- A `kind` column on `registry_plugins`.
- `ListCategories` RPC added to the existing `PluginRegistryService`
(one service is fine at this size).
- Archive validation helper (open zstd, walk tar, extract `plugin.mod`).
- `REGISTRY_PUBLISH_MAX_BYTES` config (default `25 * 1024 * 1024`).
CLI:
- `bump`, `version` (just shipped, no change).
- Init prompts for kind + categories; calls `ListCategories`.
- Publish builds `tar.zst` from `git archive HEAD`.
Proto:
- `Plugin.kind`, `CreatePluginRequest.kind/categories`,
`ListPluginsRequest.kind/categories`, new `ListCategories*` messages,
`PublishVersionRequest.archive` (bytes), removal of `git_ref` and the
top-level `git_remote_url` from `CreatePluginResponse`.
## Open questions
- What is the starter category list? Seed something reasonable; admins
can curate it later. Suggested starting set: `analytics`, `seo`,
`social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
## Out of scope
- Admin UI for managing categories (use a migration for now).
- Category translations / i18n.
- Plugin search by category in the install UI (data is there once the
field is populated; consumers can use it when ready).
- Auth for the publish endpoint — already in place via existing bearer
token interceptor.

1
go.mod
View File

@ -18,6 +18,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect

2
go.sum
View File

@ -24,6 +24,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=

View File

@ -63,6 +63,9 @@ const (
// PluginRegistryServiceResolveInstallProcedure is the fully-qualified name of the // PluginRegistryServiceResolveInstallProcedure is the fully-qualified name of the
// PluginRegistryService's ResolveInstall RPC. // PluginRegistryService's ResolveInstall RPC.
PluginRegistryServiceResolveInstallProcedure = "/orchestrator.v1.PluginRegistryService/ResolveInstall" PluginRegistryServiceResolveInstallProcedure = "/orchestrator.v1.PluginRegistryService/ResolveInstall"
// PluginRegistryServiceListCategoriesProcedure is the fully-qualified name of the
// PluginRegistryService's ListCategories RPC.
PluginRegistryServiceListCategoriesProcedure = "/orchestrator.v1.PluginRegistryService/ListCategories"
// PluginPublishServicePublishVersionProcedure is the fully-qualified name of the // PluginPublishServicePublishVersionProcedure is the fully-qualified name of the
// PluginPublishService's PublishVersion RPC. // PluginPublishService's PublishVersion RPC.
PluginPublishServicePublishVersionProcedure = "/orchestrator.v1.PluginPublishService/PublishVersion" PluginPublishServicePublishVersionProcedure = "/orchestrator.v1.PluginPublishService/PublishVersion"
@ -209,6 +212,7 @@ type PluginRegistryServiceClient interface {
ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error) ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error)
GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error) GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error)
ResolveInstall(context.Context, *connect.Request[v1.ResolveInstallRequest]) (*connect.Response[v1.ResolveInstallResponse], error) ResolveInstall(context.Context, *connect.Request[v1.ResolveInstallRequest]) (*connect.Response[v1.ResolveInstallResponse], error)
ListCategories(context.Context, *connect.Request[v1.ListCategoriesRequest]) (*connect.Response[v1.ListCategoriesResponse], error)
} }
// NewPluginRegistryServiceClient constructs a client for the orchestrator.v1.PluginRegistryService // NewPluginRegistryServiceClient constructs a client for the orchestrator.v1.PluginRegistryService
@ -252,6 +256,12 @@ func NewPluginRegistryServiceClient(httpClient connect.HTTPClient, baseURL strin
connect.WithSchema(pluginRegistryServiceMethods.ByName("ResolveInstall")), connect.WithSchema(pluginRegistryServiceMethods.ByName("ResolveInstall")),
connect.WithClientOptions(opts...), connect.WithClientOptions(opts...),
), ),
listCategories: connect.NewClient[v1.ListCategoriesRequest, v1.ListCategoriesResponse](
httpClient,
baseURL+PluginRegistryServiceListCategoriesProcedure,
connect.WithSchema(pluginRegistryServiceMethods.ByName("ListCategories")),
connect.WithClientOptions(opts...),
),
} }
} }
@ -262,6 +272,7 @@ type pluginRegistryServiceClient struct {
listPlugins *connect.Client[v1.ListPluginsRequest, v1.ListPluginsResponse] listPlugins *connect.Client[v1.ListPluginsRequest, v1.ListPluginsResponse]
getVersion *connect.Client[v1.GetVersionRequest, v1.GetVersionResponse] getVersion *connect.Client[v1.GetVersionRequest, v1.GetVersionResponse]
resolveInstall *connect.Client[v1.ResolveInstallRequest, v1.ResolveInstallResponse] resolveInstall *connect.Client[v1.ResolveInstallRequest, v1.ResolveInstallResponse]
listCategories *connect.Client[v1.ListCategoriesRequest, v1.ListCategoriesResponse]
} }
// CreatePlugin calls orchestrator.v1.PluginRegistryService.CreatePlugin. // CreatePlugin calls orchestrator.v1.PluginRegistryService.CreatePlugin.
@ -289,6 +300,11 @@ func (c *pluginRegistryServiceClient) ResolveInstall(ctx context.Context, req *c
return c.resolveInstall.CallUnary(ctx, req) return c.resolveInstall.CallUnary(ctx, req)
} }
// ListCategories calls orchestrator.v1.PluginRegistryService.ListCategories.
func (c *pluginRegistryServiceClient) ListCategories(ctx context.Context, req *connect.Request[v1.ListCategoriesRequest]) (*connect.Response[v1.ListCategoriesResponse], error) {
return c.listCategories.CallUnary(ctx, req)
}
// PluginRegistryServiceHandler is an implementation of the orchestrator.v1.PluginRegistryService // PluginRegistryServiceHandler is an implementation of the orchestrator.v1.PluginRegistryService
// service. // service.
type PluginRegistryServiceHandler interface { type PluginRegistryServiceHandler interface {
@ -297,6 +313,7 @@ type PluginRegistryServiceHandler interface {
ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error) ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], error)
GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error) GetVersion(context.Context, *connect.Request[v1.GetVersionRequest]) (*connect.Response[v1.GetVersionResponse], error)
ResolveInstall(context.Context, *connect.Request[v1.ResolveInstallRequest]) (*connect.Response[v1.ResolveInstallResponse], error) ResolveInstall(context.Context, *connect.Request[v1.ResolveInstallRequest]) (*connect.Response[v1.ResolveInstallResponse], error)
ListCategories(context.Context, *connect.Request[v1.ListCategoriesRequest]) (*connect.Response[v1.ListCategoriesResponse], error)
} }
// NewPluginRegistryServiceHandler builds an HTTP handler from the service implementation. It // NewPluginRegistryServiceHandler builds an HTTP handler from the service implementation. It
@ -336,6 +353,12 @@ func NewPluginRegistryServiceHandler(svc PluginRegistryServiceHandler, opts ...c
connect.WithSchema(pluginRegistryServiceMethods.ByName("ResolveInstall")), connect.WithSchema(pluginRegistryServiceMethods.ByName("ResolveInstall")),
connect.WithHandlerOptions(opts...), connect.WithHandlerOptions(opts...),
) )
pluginRegistryServiceListCategoriesHandler := connect.NewUnaryHandler(
PluginRegistryServiceListCategoriesProcedure,
svc.ListCategories,
connect.WithSchema(pluginRegistryServiceMethods.ByName("ListCategories")),
connect.WithHandlerOptions(opts...),
)
return "/orchestrator.v1.PluginRegistryService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return "/orchestrator.v1.PluginRegistryService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case PluginRegistryServiceCreatePluginProcedure: case PluginRegistryServiceCreatePluginProcedure:
@ -348,6 +371,8 @@ func NewPluginRegistryServiceHandler(svc PluginRegistryServiceHandler, opts ...c
pluginRegistryServiceGetVersionHandler.ServeHTTP(w, r) pluginRegistryServiceGetVersionHandler.ServeHTTP(w, r)
case PluginRegistryServiceResolveInstallProcedure: case PluginRegistryServiceResolveInstallProcedure:
pluginRegistryServiceResolveInstallHandler.ServeHTTP(w, r) pluginRegistryServiceResolveInstallHandler.ServeHTTP(w, r)
case PluginRegistryServiceListCategoriesProcedure:
pluginRegistryServiceListCategoriesHandler.ServeHTTP(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -377,6 +402,10 @@ func (UnimplementedPluginRegistryServiceHandler) ResolveInstall(context.Context,
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginRegistryService.ResolveInstall is not implemented")) return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginRegistryService.ResolveInstall is not implemented"))
} }
func (UnimplementedPluginRegistryServiceHandler) ListCategories(context.Context, *connect.Request[v1.ListCategoriesRequest]) (*connect.Response[v1.ListCategoriesResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("orchestrator.v1.PluginRegistryService.ListCategories is not implemented"))
}
// PluginPublishServiceClient is a client for the orchestrator.v1.PluginPublishService service. // PluginPublishServiceClient is a client for the orchestrator.v1.PluginPublishService service.
type PluginPublishServiceClient interface { type PluginPublishServiceClient interface {
PublishVersion(context.Context, *connect.Request[v1.PublishVersionRequest]) (*connect.Response[v1.PublishVersionResponse], error) PublishVersion(context.Context, *connect.Request[v1.PublishVersionRequest]) (*connect.Response[v1.PublishVersionResponse], error)

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,11 @@ type ModFile struct {
} }
type ModPlugin struct { type ModPlugin struct {
Name string `toml:"name"` Name string `toml:"name"`
Scope string `toml:"scope"` Scope string `toml:"scope"`
Version string `toml:"version"` Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
Categories []string `toml:"categories,omitempty"`
} }
type ModCompat struct { type ModCompat struct {

View File

@ -64,6 +64,42 @@ func TestParseModFull_EmptyInput(t *testing.T) {
} }
} }
func TestParseModFull_KindAndCategories(t *testing.T) {
src := []byte(`
[plugin]
name = "analyser"
scope = "blockninja"
version = "0.1.0"
kind = "plugin"
categories = ["analytics", "seo"]
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Kind != "plugin" {
t.Errorf("Kind = %q, want plugin", m.Plugin.Kind)
}
if got := m.Plugin.Categories; len(got) != 2 || got[0] != "analytics" || got[1] != "seo" {
t.Errorf("Categories = %v", got)
}
}
func TestParseModFull_KindDefaultsEmpty(t *testing.T) {
src := []byte(`
[plugin]
name = "legacy"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if m.Plugin.Kind != "" {
t.Errorf("Kind should be empty for legacy mod, got %q", m.Plugin.Kind)
}
}
func TestParseModFull_RequiresAndCompat(t *testing.T) { func TestParseModFull_RequiresAndCompat(t *testing.T) {
src := []byte(` src := []byte(`
[plugin] [plugin]

View File

@ -3,6 +3,8 @@ package plugin
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"fmt"
"strconv"
"strings" "strings"
"golang.org/x/mod/semver" "golang.org/x/mod/semver"
@ -44,3 +46,44 @@ func CompareVersions(v1, v2 string) int {
} }
return semver.Compare(v1, v2) return semver.Compare(v1, v2)
} }
// ParseBaseSemver parses a plain MAJOR.MINOR.PATCH version into integers.
// Rejects pre-release suffixes and build metadata.
func ParseBaseSemver(s string) (major, minor, patch int, err error) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s)
}
out := [3]int{}
for i, p := range parts {
n, perr := strconv.Atoi(p)
if perr != nil || n < 0 {
return 0, 0, 0, fmt.Errorf("invalid version %q: each part must be a non-negative integer", s)
}
out[i] = n
}
return out[0], out[1], out[2], nil
}
// BumpVersion returns current bumped at the given level ("major", "minor", or "patch").
// Bumping major resets minor and patch to 0; bumping minor resets patch to 0.
func BumpVersion(current, level string) (string, error) {
major, minor, patch, err := ParseBaseSemver(current)
if err != nil {
return "", err
}
switch level {
case "major":
major++
minor = 0
patch = 0
case "minor":
minor++
patch = 0
case "patch":
patch++
default:
return "", fmt.Errorf("unknown bump level %q (want major|minor|patch)", level)
}
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
}

77
plugin/version_test.go Normal file
View File

@ -0,0 +1,77 @@
package plugin
import "testing"
func TestParseBaseSemver(t *testing.T) {
cases := []struct {
in string
major, minor, patch int
wantErr bool
}{
{"0.1.0", 0, 1, 0, false},
{"1.0.0", 1, 0, 0, false},
{"12.34.567", 12, 34, 567, false},
{"0.0.0", 0, 0, 0, false},
{"v0.1.0", 0, 0, 0, true},
{"0.1", 0, 0, 0, true},
{"0.1.0.0", 0, 0, 0, true},
{"0.1.0-beta", 0, 0, 0, true},
{"0.1.0+build", 0, 0, 0, true},
{"-1.0.0", 0, 0, 0, true},
{"a.b.c", 0, 0, 0, true},
{"", 0, 0, 0, true},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
maj, min, pat, err := ParseBaseSemver(c.in)
if c.wantErr {
if err == nil {
t.Fatalf("ParseBaseSemver(%q): expected error, got %d.%d.%d", c.in, maj, min, pat)
}
return
}
if err != nil {
t.Fatalf("ParseBaseSemver(%q): unexpected error: %v", c.in, err)
}
if maj != c.major || min != c.minor || pat != c.patch {
t.Errorf("ParseBaseSemver(%q) = %d.%d.%d, want %d.%d.%d", c.in, maj, min, pat, c.major, c.minor, c.patch)
}
})
}
}
func TestBumpVersion(t *testing.T) {
cases := []struct {
current, level, want string
wantErr bool
}{
{"0.1.0", "patch", "0.1.1", false},
{"0.1.0", "minor", "0.2.0", false},
{"0.1.0", "major", "1.0.0", false},
{"1.2.3", "patch", "1.2.4", false},
{"1.2.3", "minor", "1.3.0", false},
{"1.2.3", "major", "2.0.0", false},
{"0.0.0", "patch", "0.0.1", false},
{"0.1.0", "build", "", true},
{"0.1.0", "", "", true},
{"v0.1.0", "patch", "", true},
{"", "patch", "", true},
}
for _, c := range cases {
t.Run(c.current+"/"+c.level, func(t *testing.T) {
got, err := BumpVersion(c.current, c.level)
if c.wantErr {
if err == nil {
t.Fatalf("BumpVersion(%q, %q): expected error, got %q", c.current, c.level, got)
}
return
}
if err != nil {
t.Fatalf("BumpVersion(%q, %q): unexpected error: %v", c.current, c.level, err)
}
if got != c.want {
t.Errorf("BumpVersion(%q, %q) = %q, want %q", c.current, c.level, got, c.want)
}
})
}
}

View File

@ -20,6 +20,7 @@ service PluginRegistryService {
rpc ListPlugins(ListPluginsRequest) returns (ListPluginsResponse); rpc ListPlugins(ListPluginsRequest) returns (ListPluginsResponse);
rpc GetVersion(GetVersionRequest) returns (GetVersionResponse); rpc GetVersion(GetVersionRequest) returns (GetVersionResponse);
rpc ResolveInstall(ResolveInstallRequest) returns (ResolveInstallResponse); rpc ResolveInstall(ResolveInstallRequest) returns (ResolveInstallResponse);
rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse);
} }
// PluginPublishService is called by the ninja CLI to publish a version. // PluginPublishService is called by the ninja CLI to publish a version.
@ -54,14 +55,20 @@ message Plugin {
string homepage_url = 7; string homepage_url = 7;
repeated string categories = 8; repeated string categories = 8;
google.protobuf.Timestamp updated_at = 9; google.protobuf.Timestamp updated_at = 9;
string kind = 10;
}
message Category {
string slug = 1;
string display_name = 2;
string description = 3;
int32 sort_order = 4;
} }
message Version { message Version {
string id = 1; string id = 1;
string plugin_id = 2; string plugin_id = 2;
string version = 3; string version = 3;
string git_commit = 4;
string git_tag = 5;
google.protobuf.Timestamp published_at = 6; google.protobuf.Timestamp published_at = 6;
bool yanked = 7; bool yanked = 7;
string sdk_constraint = 8; string sdk_constraint = 8;
@ -90,10 +97,11 @@ message CreatePluginRequest {
string scope_slug = 1; string scope_slug = 1;
string name = 2; string name = 2;
string description = 3; string description = 3;
string kind = 4;
repeated string categories = 5;
} }
message CreatePluginResponse { message CreatePluginResponse {
Plugin plugin = 1; Plugin plugin = 1;
string git_remote_url = 2;
} }
message GetPluginRequest { string scope_slug = 1; string name = 2; } message GetPluginRequest { string scope_slug = 1; string name = 2; }
@ -107,9 +115,14 @@ message ListPluginsRequest {
int32 limit = 1; int32 limit = 1;
int32 offset = 2; int32 offset = 2;
string query = 3; string query = 3;
string kind = 4;
repeated string categories = 5;
} }
message ListPluginsResponse { repeated Plugin plugins = 1; } message ListPluginsResponse { repeated Plugin plugins = 1; }
message ListCategoriesRequest {}
message ListCategoriesResponse { repeated Category categories = 1; }
message GetVersionRequest { message GetVersionRequest {
string scope_slug = 1; string scope_slug = 1;
string plugin_name = 2; string plugin_name = 2;
@ -145,10 +158,11 @@ message ResolveInstallResponse {
message PublishVersionRequest { message PublishVersionRequest {
string plugin_id = 1; string plugin_id = 1;
string git_ref = 2; string version = 2;
string channel = 3; string channel = 3;
string readme_md = 4; bytes archive = 4;
string changelog_md = 5; string readme_md = 5;
string changelog_md = 6;
} }
message PublishVersionResponse { message PublishVersionResponse {
Version version = 1; Version version = 1;