Compare commits
12 Commits
32c6528162
...
a79aa709c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a79aa709c2 | ||
|
|
08be22ec34 | ||
|
|
aafdc44f6f | ||
|
|
57a217f54d | ||
|
|
c825942c8d | ||
|
|
e5b27f5a65 | ||
|
|
a827cda37a | ||
|
|
31e7b72b49 | ||
|
|
680cbe0160 | ||
|
|
e9bef5b065 | ||
|
|
2a76b30c51 | ||
|
|
1d9ca44f55 |
@ -33,7 +33,7 @@ func newLoginCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
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
|
||||
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
@ -12,6 +16,7 @@ import (
|
||||
|
||||
core "git.dev.alexdunmow.com/block/core/plugin"
|
||||
|
||||
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/archive"
|
||||
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
|
||||
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
|
||||
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
|
||||
@ -19,7 +24,13 @@ import (
|
||||
|
||||
func newPluginCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
|
||||
c.AddCommand(newPluginInitCmd(), newPluginPublishCmd(), newPluginStatusCmd())
|
||||
c.AddCommand(
|
||||
newPluginInitCmd(),
|
||||
newPluginPublishCmd(),
|
||||
newPluginStatusCmd(),
|
||||
newPluginBumpCmd(),
|
||||
newPluginVersionCmd(),
|
||||
)
|
||||
return c
|
||||
}
|
||||
|
||||
@ -39,49 +50,160 @@ func newPluginInitCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
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()
|
||||
resp, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
|
||||
ScopeSlug: scope, Name: name, Description: "",
|
||||
}))
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
if scope != "" {
|
||||
scope, err = parseScope(scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Created @%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")
|
||||
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
|
||||
}
|
||||
fmt.Printf("\nCreated %s/%s\n", scope, name)
|
||||
|
||||
if err := upsertPluginMod(scope, name); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
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")
|
||||
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 {
|
||||
var channel string
|
||||
var allowDirty bool
|
||||
cmd := &cobra.Command{
|
||||
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 {
|
||||
host, _ := c.Flags().GetString("host")
|
||||
cr, err := creds.Load()
|
||||
@ -113,14 +235,19 @@ func newPluginPublishCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
tag := "v" + mod.Plugin.Version
|
||||
if err := runCmd("git", "tag", "-a", tag, "-m", tag); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tag %s already exists or could not be created (%v); attempting push\n", tag, err)
|
||||
out, _ := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard").Output()
|
||||
if names := strings.TrimSpace(string(out)); names != "" {
|
||||
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)
|
||||
}
|
||||
if err := runCmd("git", "push", "ninja", tag); err != nil {
|
||||
return fmt.Errorf("git push ninja %s: %w", tag, err)
|
||||
fmt.Fprintln(os.Stderr, " (run `git rm --cached <file>` to drop)")
|
||||
}
|
||||
|
||||
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()
|
||||
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{
|
||||
PluginId: pr.Msg.Plugin.Id,
|
||||
GitRef: "refs/tags/" + tag,
|
||||
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 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 {
|
||||
fmt.Printf(" warning: %s\n", w)
|
||||
}
|
||||
fmt.Printf(" Archive: %s\n", pubResp.Msg.ArchiveUrl)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@ -177,7 +304,7 @@ func newPluginStatusCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
c := exec.Command(name, args...)
|
||||
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("scope = %q\n", m.Plugin.Scope))
|
||||
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 {
|
||||
b.WriteString("\n[compatibility]\n")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -14,5 +14,6 @@ func NewRoot() *cobra.Command {
|
||||
root.AddCommand(newLogoutCmd())
|
||||
root.AddCommand(newWhoamiCmd())
|
||||
root.AddCommand(newPluginCmd())
|
||||
root.AddCommand(newScopeCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
230
cmd/ninja/cmd/scope.go
Normal file
230
cmd/ninja/cmd/scope.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
41
cmd/ninja/internal/archive/archive.go
Normal file
41
cmd/ninja/internal/archive/archive.go
Normal 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
|
||||
}
|
||||
89
cmd/ninja/internal/archive/archive_test.go
Normal file
89
cmd/ninja/internal/archive/archive_test.go
Normal 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
|
||||
}
|
||||
@ -15,6 +15,7 @@ type Credentials struct {
|
||||
type HostCreds struct {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user,omitempty"`
|
||||
DefaultScope string `json:"default_scope,omitempty"`
|
||||
}
|
||||
|
||||
func filePath() (string, error) {
|
||||
|
||||
@ -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.
|
||||
@ -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 (A–E) — 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
|
||||
2350
docs/superpowers/plans/2026-06-03-plugin-publish-and-categories.md
Normal file
2350
docs/superpowers/plans/2026-06-03-plugin-publish-and-categories.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
1
go.mod
@ -18,6 +18,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // 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/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@ -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/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/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/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
|
||||
@ -63,6 +63,9 @@ const (
|
||||
// PluginRegistryServiceResolveInstallProcedure is the fully-qualified name of the
|
||||
// PluginRegistryService's ResolveInstall RPC.
|
||||
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
|
||||
// PluginPublishService's PublishVersion RPC.
|
||||
PluginPublishServicePublishVersionProcedure = "/orchestrator.v1.PluginPublishService/PublishVersion"
|
||||
@ -209,6 +212,7 @@ type PluginRegistryServiceClient interface {
|
||||
ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], 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)
|
||||
ListCategories(context.Context, *connect.Request[v1.ListCategoriesRequest]) (*connect.Response[v1.ListCategoriesResponse], error)
|
||||
}
|
||||
|
||||
// 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.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]
|
||||
getVersion *connect.Client[v1.GetVersionRequest, v1.GetVersionResponse]
|
||||
resolveInstall *connect.Client[v1.ResolveInstallRequest, v1.ResolveInstallResponse]
|
||||
listCategories *connect.Client[v1.ListCategoriesRequest, v1.ListCategoriesResponse]
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// service.
|
||||
type PluginRegistryServiceHandler interface {
|
||||
@ -297,6 +313,7 @@ type PluginRegistryServiceHandler interface {
|
||||
ListPlugins(context.Context, *connect.Request[v1.ListPluginsRequest]) (*connect.Response[v1.ListPluginsResponse], 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)
|
||||
ListCategories(context.Context, *connect.Request[v1.ListCategoriesRequest]) (*connect.Response[v1.ListCategoriesResponse], error)
|
||||
}
|
||||
|
||||
// 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.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) {
|
||||
switch r.URL.Path {
|
||||
case PluginRegistryServiceCreatePluginProcedure:
|
||||
@ -348,6 +371,8 @@ func NewPluginRegistryServiceHandler(svc PluginRegistryServiceHandler, opts ...c
|
||||
pluginRegistryServiceGetVersionHandler.ServeHTTP(w, r)
|
||||
case PluginRegistryServiceResolveInstallProcedure:
|
||||
pluginRegistryServiceResolveInstallHandler.ServeHTTP(w, r)
|
||||
case PluginRegistryServiceListCategoriesProcedure:
|
||||
pluginRegistryServiceListCategoriesHandler.ServeHTTP(w, r)
|
||||
default:
|
||||
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"))
|
||||
}
|
||||
|
||||
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.
|
||||
type PluginPublishServiceClient interface {
|
||||
PublishVersion(context.Context, *connect.Request[v1.PublishVersionRequest]) (*connect.Response[v1.PublishVersionResponse], error)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,8 @@ type ModPlugin struct {
|
||||
Name string `toml:"name"`
|
||||
Scope string `toml:"scope"`
|
||||
Version string `toml:"version"`
|
||||
Kind string `toml:"kind,omitempty"`
|
||||
Categories []string `toml:"categories,omitempty"`
|
||||
}
|
||||
|
||||
type ModCompat struct {
|
||||
|
||||
@ -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) {
|
||||
src := []byte(`
|
||||
[plugin]
|
||||
|
||||
@ -3,6 +3,8 @@ package plugin
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
@ -44,3 +46,44 @@ func CompareVersions(v1, v2 string) int {
|
||||
}
|
||||
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
77
plugin/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,7 @@ service PluginRegistryService {
|
||||
rpc ListPlugins(ListPluginsRequest) returns (ListPluginsResponse);
|
||||
rpc GetVersion(GetVersionRequest) returns (GetVersionResponse);
|
||||
rpc ResolveInstall(ResolveInstallRequest) returns (ResolveInstallResponse);
|
||||
rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse);
|
||||
}
|
||||
|
||||
// PluginPublishService is called by the ninja CLI to publish a version.
|
||||
@ -54,14 +55,20 @@ message Plugin {
|
||||
string homepage_url = 7;
|
||||
repeated string categories = 8;
|
||||
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 {
|
||||
string id = 1;
|
||||
string plugin_id = 2;
|
||||
string version = 3;
|
||||
string git_commit = 4;
|
||||
string git_tag = 5;
|
||||
google.protobuf.Timestamp published_at = 6;
|
||||
bool yanked = 7;
|
||||
string sdk_constraint = 8;
|
||||
@ -90,10 +97,11 @@ message CreatePluginRequest {
|
||||
string scope_slug = 1;
|
||||
string name = 2;
|
||||
string description = 3;
|
||||
string kind = 4;
|
||||
repeated string categories = 5;
|
||||
}
|
||||
message CreatePluginResponse {
|
||||
Plugin plugin = 1;
|
||||
string git_remote_url = 2;
|
||||
}
|
||||
|
||||
message GetPluginRequest { string scope_slug = 1; string name = 2; }
|
||||
@ -107,9 +115,14 @@ message ListPluginsRequest {
|
||||
int32 limit = 1;
|
||||
int32 offset = 2;
|
||||
string query = 3;
|
||||
string kind = 4;
|
||||
repeated string categories = 5;
|
||||
}
|
||||
message ListPluginsResponse { repeated Plugin plugins = 1; }
|
||||
|
||||
message ListCategoriesRequest {}
|
||||
message ListCategoriesResponse { repeated Category categories = 1; }
|
||||
|
||||
message GetVersionRequest {
|
||||
string scope_slug = 1;
|
||||
string plugin_name = 2;
|
||||
@ -145,10 +158,11 @@ message ResolveInstallResponse {
|
||||
|
||||
message PublishVersionRequest {
|
||||
string plugin_id = 1;
|
||||
string git_ref = 2;
|
||||
string version = 2;
|
||||
string channel = 3;
|
||||
string readme_md = 4;
|
||||
string changelog_md = 5;
|
||||
bytes archive = 4;
|
||||
string readme_md = 5;
|
||||
string changelog_md = 6;
|
||||
}
|
||||
message PublishVersionResponse {
|
||||
Version version = 1;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user