core/cmd/ninja/cmd/plugin.go
Alex Dunmow fda01e81b5 refactor(cli): extract publish-time warnings into testable helpers
Pull the three inline warning blocks in newPluginPublishCmd —
gitignoredTrackedWarning, untrackedFilesWarning, submoduleWarning — into
package-private helpers that take a repo dir and an io.Writer. Output is
byte-identical to the previous inline code; this just makes them unit-
testable without driving the whole cobra command.
2026-06-03 09:12:56 +08:00

713 lines
20 KiB
Go

package cmd
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"connectrpc.com/connect"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/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
}
// Run the tracked-yet-gitignored warning BEFORE the dirty check so
// the developer sees it even on the aborted-publish path; the spec
// asks for this warning to be unconditional.
gitignoredTrackedWarning(".", os.Stderr)
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.
untrackedFilesWarning(".", os.Stderr)
}
// `git archive` does not recurse into submodules, so any submodule
// paths will appear as empty directories in the tarball. Detect via
// .gitmodules so this works even for submodules that haven't been
// initialised yet.
submoduleWarning(".", os.Stderr)
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
}
// submodulePaths returns the configured submodule paths from .gitmodules.
// Reading the file directly (rather than running `git submodule status`) means
// we detect submodules that have been declared but not yet initialised.
func submodulePaths(repoDir string) []string {
cmd := exec.Command("git", "config", "--file", ".gitmodules", "--get-regexp", `submodule\..*\.path`)
cmd.Dir = repoDir
out, err := cmd.Output()
if err != nil {
return nil
}
var paths []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
// each line is "submodule.<name>.path <path>"
fields := strings.Fields(line)
if len(fields) >= 2 {
paths = append(paths, fields[len(fields)-1])
}
}
return paths
}
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)
}
// gitignoredTrackedWarning writes a warning to w if any tracked files
// in repoDir match the active .gitignore — those files still ship in the
// archive, which is almost always not what the author wants.
func gitignoredTrackedWarning(repoDir string, w io.Writer) {
cmd := exec.Command("git", "ls-files", "--cached", "--ignored", "--exclude-standard")
cmd.Dir = repoDir
out, _ := cmd.Output()
names := strings.TrimSpace(string(out))
if names == "" {
return
}
fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(w, " "+n)
}
fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
}
// untrackedFilesWarning writes a warning to w listing untracked files in
// repoDir. Used on the --allow-dirty publish path because `git stash create`
// only captures tracked content, so untracked files silently vanish from the
// archive.
func untrackedFilesWarning(repoDir string, w io.Writer) {
cmd := exec.Command("git", "ls-files", "--others", "--exclude-standard")
cmd.Dir = repoDir
out, _ := cmd.Output()
names := strings.TrimSpace(string(out))
if names == "" {
return
}
fmt.Fprintln(w, "warning: --allow-dirty: these untracked files will NOT be in the archive:")
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(w, " "+n)
}
fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")
}
// submoduleWarning writes a warning to w if repoDir contains submodules.
// `git archive` doesn't recurse into them so they ship as empty directories;
// detection is via .gitmodules so it fires even for uninitialised submodules.
func submoduleWarning(repoDir string, w io.Writer) {
paths := submodulePaths(repoDir)
if len(paths) == 0 {
return
}
fmt.Fprintln(w, "warning: this repo has submodules; git archive will ship them as empty directories:")
for _, p := range paths {
fmt.Fprintln(w, " "+p)
}
fmt.Fprintln(w, " (vendor the contents or pack them separately if the plugin depends on them)")
}
// autoCommitPluginMod stages and commits plugin.mod 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
}