core/cmd/ninja/cmd/plugin.go
Alex Dunmow c3cfa18ae0 fix(cli): warn about untracked files when publishing with --allow-dirty
`git stash create` only captures tracked content, so a developer using
--allow-dirty after creating new files (but forgetting to `git add`)
would ship a tarball missing them with no indication. Now publish lists
the untracked, non-ignored files to stderr and suggests `git add` when
--allow-dirty is in play.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:56:43 +08:00

644 lines
18 KiB
Go

package cmd
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/archive"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
)
func newPluginCmd() *cobra.Command {
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
c.AddCommand(
newPluginInitCmd(),
newPluginPublishCmd(),
newPluginStatusCmd(),
newPluginBumpCmd(),
newPluginVersionCmd(),
)
return c
}
func newPluginInitCmd() *cobra.Command {
var scope, name string
cmd := &cobra.Command{
Use: "init",
Short: "Create a plugin in the registry and add a git remote",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scanner := bufio.NewScanner(os.Stdin)
if scope != "" {
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")
}
}
kind, err := promptKind(scanner)
if err != nil {
return err
}
var cats []string
if kind == "plugin" {
cats, err = promptCategories(ctx, cli, scanner)
if err != nil {
return err
}
}
if _, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
ScopeSlug: scopeAPISlug(scope),
Name: name,
Description: "",
Kind: kind,
Categories: cats,
})); err != nil {
return err
}
fmt.Printf("\nCreated %s/%s\n", scope, name)
if err := upsertPluginMod(scope, name, kind, cats); 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 && git commit` before `ninja plugin publish`")
}
return nil
},
}
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 promptKind(scanner *bufio.Scanner) (string, error) {
fmt.Println("Kind: 1) plugin 2) theme")
fmt.Print("Select [1]: ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
v := strings.TrimSpace(scanner.Text())
switch v {
case "", "1", "plugin":
return "plugin", nil
case "2", "theme":
return "theme", nil
default:
return "", fmt.Errorf("invalid kind: %s", v)
}
}
func promptCategories(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner) ([]string, error) {
resp, err := cli.Reg.ListCategories(ctx, connect.NewRequest(&v1.ListCategoriesRequest{}))
if err != nil {
return nil, fmt.Errorf("list categories: %w", err)
}
cats := resp.Msg.Categories
if len(cats) == 0 {
return nil, nil
}
fmt.Println("Categories (comma-separated numbers, or blank to skip):")
for i, c := range cats {
fmt.Printf(" %d. %s — %s\n", i+1, c.Slug, c.DisplayName)
}
fmt.Print("Select: ")
if !scanner.Scan() {
return nil, fmt.Errorf("cancelled")
}
raw := strings.TrimSpace(scanner.Text())
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil || n < 1 || n > len(cats) {
return nil, fmt.Errorf("invalid category selection: %s", p)
}
out = append(out, cats[n-1].Slug)
}
return out, nil
}
func newPluginPublishCmd() *cobra.Command {
var channel string
var allowDirty bool
cmd := &cobra.Command{
Use: "publish",
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()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
modBytes, err := os.ReadFile("plugin.mod")
if err != nil {
return fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return err
}
if mod.Plugin.Scope == "" || mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return fmt.Errorf("plugin.mod must have scope, name, and version")
}
if err := checkRepoHasHEAD("."); err != nil {
return err
}
if !allowDirty {
out, _ := exec.Command("git", "status", "--porcelain").Output()
if len(strings.TrimSpace(string(out))) > 0 {
return fmt.Errorf("working tree dirty; commit or pass --allow-dirty")
}
} else {
// `git stash create` only captures tracked content, so untracked
// files would be silently dropped from the archive. Warn loudly.
out, _ := exec.Command("git", "ls-files", "--others", "--exclude-standard").Output()
if names := strings.TrimSpace(string(out)); names != "" {
fmt.Fprintln(os.Stderr, "warning: --allow-dirty: these untracked files will NOT be in the archive:")
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(os.Stderr, " "+n)
}
fmt.Fprintln(os.Stderr, " (run `git add <file>` if they should be shipped)")
}
}
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)
}
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)
}
ctx := context.Background()
pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(&v1.GetPluginRequest{
ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name,
}))
if err != nil {
return fmt.Errorf("get plugin: %w", err)
}
readme, _ := os.ReadFile("README.md")
changelog, _ := os.ReadFile("CHANGELOG.md")
pubResp, err := cli.Pub.PublishVersion(ctx, connect.NewRequest(&v1.PublishVersionRequest{
PluginId: pr.Msg.Plugin.Id,
Version: mod.Plugin.Version,
Channel: channel,
Archive: archiveBytes,
ReadmeMd: string(readme),
ChangelogMd: string(changelog),
}))
if err != nil {
return fmt.Errorf("publish: %w", err)
}
fmt.Printf("Published %s (%d bytes)\n", mod.Coords(), len(archiveBytes))
for _, w := range pubResp.Msg.Warnings {
fmt.Printf(" warning: %s\n", w)
}
return nil
},
}
cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version")
cmd.Flags().BoolVar(&allowDirty, "allow-dirty", false, "Skip clean-working-tree check")
return cmd
}
func newPluginStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "List owned scopes and their plugins",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
for _, s := range scopes.Msg.Scopes {
fmt.Printf("@%s - %s\n", s.Slug, s.DisplayName)
gs, err := cli.Scope.GetScope(ctx, connect.NewRequest(&v1.GetScopeRequest{Slug: s.Slug}))
if err != nil {
fmt.Printf(" (error: %v)\n", err)
continue
}
for _, p := range gs.Msg.Plugins {
fmt.Printf(" @%s/%s [%s]\n", s.Slug, p.Name, p.Visibility)
}
}
return nil
},
}
}
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
return c.Run()
}
// checkRepoHasHEAD returns a friendlier error than the raw git failure when
// the publish flow is invoked in a freshly-initialised repository that has
// no commits yet. Without this, `git stash create` reports "You do not have
// the initial commit yet" via exit status 128, which surfaces as an internal
// failure to the user.
func checkRepoHasHEAD(repoDir string) error {
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
return fmt.Errorf("no commits in repository; run `git add . && git commit` before publishing")
}
return nil
}
func upsertPluginMod(scope, name, kind string, categories []string) error {
const file = "plugin.mod"
existing, _ := os.ReadFile(file)
mod, _ := core.ParseModFull(existing)
if mod == nil {
mod = &core.ModFile{}
}
if mod.Plugin.Version == "" {
mod.Plugin.Version = "0.1.0"
}
mod.Plugin.Scope = scope
mod.Plugin.Name = name
mod.Plugin.Kind = kind
mod.Plugin.Categories = categories
return writeMod(file, mod)
}
func writeMod(path string, m *core.ModFile) error {
var b strings.Builder
b.WriteString("[plugin]\n")
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))
}
for _, r := range m.Requires {
b.WriteString("\n[[requires]]\n")
b.WriteString(fmt.Sprintf("name = %q\n", r.Name))
b.WriteString(fmt.Sprintf("version = %q\n", r.Version))
}
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
}