Add `ninja plugin gallery list|add|remove|reorder|set-featured` for author-side curation of a plugin/theme's registry screenshot gallery, feeding the browse/detail surfaces shipped in Phases 4-5. - Re-vendor plugin_registry.proto from orchestrator (adds PluginGalleryService) and regenerate the connect client. - Wire the Gallery client into orchclient.Client. - Target plugin defaults from plugin.mod with --scope/--name overrides; device-flow auth via resolveClient. list is a public read; add/remove/ reorder/set-featured are RoleUser (scope/account membership enforced server-side). add derives content_type from the extension against the png/jpg/jpeg/webp allowlist and rejects >8MiB client-side. Scope handling trims a leading @ client-side because ListScreenshots does not trim server-side (GetPlugin does). Live-verified against the dev orchestrator; go build + go test ./... + check-safety all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1178 lines
36 KiB
Go
1178 lines
36 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"connectrpc.com/connect"
|
|
"github.com/spf13/cobra"
|
|
|
|
core "git.dev.alexdunmow.com/block/core/plugin"
|
|
|
|
"git.dev.alexdunmow.com/block/cli/internal/bnp"
|
|
"git.dev.alexdunmow.com/block/cli/internal/creds"
|
|
"git.dev.alexdunmow.com/block/cli/internal/orchclient"
|
|
v1 "git.dev.alexdunmow.com/block/cli/internal/api/orchestrator/v1"
|
|
)
|
|
|
|
func newPluginCmd() *cobra.Command {
|
|
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
|
|
c.AddCommand(
|
|
newPluginInitCmd(),
|
|
newPluginBuildCmd(),
|
|
newPluginVerifyCmd(),
|
|
newPluginPublishCmd(),
|
|
newPluginStatusCmd(),
|
|
newPluginListCmd(),
|
|
newPluginDeleteCmd(),
|
|
newPluginDeleteVersionCmd(),
|
|
newPluginBumpCmd(),
|
|
newPluginVersionCmd(),
|
|
newPluginTagsCmd(),
|
|
newPluginGalleryCmd(),
|
|
)
|
|
return c
|
|
}
|
|
|
|
func newPluginListCmd() *cobra.Command {
|
|
var publicOnly, privateOnly bool
|
|
cmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List your plugins, sectioned by public scope and active-account private namespace",
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
cli, _, _, hc, err := resolveClient(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx := context.Background()
|
|
if !privateOnly {
|
|
printPublicSection(ctx, cli)
|
|
}
|
|
if !publicOnly {
|
|
printPrivateSection(ctx, cli, hc)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&publicOnly, "public-only", false, "Only show plugins under public scopes")
|
|
cmd.Flags().BoolVar(&privateOnly, "private-only", false, "Only show plugins under the active account's @private namespace")
|
|
return cmd
|
|
}
|
|
|
|
func printPublicSection(ctx context.Context, cli *orchclient.Client) {
|
|
fmt.Println("Public")
|
|
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
|
|
if err != nil {
|
|
fmt.Printf(" (error: %v)\n", err)
|
|
return
|
|
}
|
|
if len(scopes.Msg.Scopes) == 0 {
|
|
fmt.Println(" (no scopes — create one with `ninja scope create`)")
|
|
return
|
|
}
|
|
for _, s := range scopes.Msg.Scopes {
|
|
gs, err := cli.Scope.GetScope(ctx, connect.NewRequest(&v1.GetScopeRequest{Slug: s.Slug}))
|
|
if err != nil {
|
|
fmt.Printf(" @%s — (error: %v)\n", s.Slug, err)
|
|
continue
|
|
}
|
|
if len(gs.Msg.Plugins) == 0 {
|
|
fmt.Printf(" @%s — (no plugins)\n", s.Slug)
|
|
continue
|
|
}
|
|
for _, p := range gs.Msg.Plugins {
|
|
fmt.Printf(" @%s/%s [%s]\n", s.Slug, p.Name, visibilityLabel(p.Visibility))
|
|
}
|
|
}
|
|
}
|
|
|
|
func newPluginDeleteCmd() *cobra.Command {
|
|
var assumeYes bool
|
|
cmd := &cobra.Command{
|
|
Use: "delete <@private/name|name>",
|
|
Short: "Delete a private plugin from your active account (all versions)",
|
|
Long: `Delete a private plugin owned by the active account, along with all its
|
|
versions. The server refuses to delete a plugin while any site in the
|
|
account has it installed; uninstall those first.
|
|
|
|
Public plugins cannot be deleted via this command.`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
cli, _, _, hc, err := resolveClient(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hc.ActiveAccountID == "" {
|
|
return fmt.Errorf("no active account; run `ninja account set <slug>`")
|
|
}
|
|
name, err := parsePrivateCoord(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !assumeYes {
|
|
return fmt.Errorf("refusing to delete without --yes")
|
|
}
|
|
_, err = cli.Reg.DeletePrivatePlugin(context.Background(),
|
|
connect.NewRequest(&v1.DeletePrivatePluginRequest{
|
|
AccountId: hc.ActiveAccountID,
|
|
PluginName: name,
|
|
}))
|
|
if err != nil {
|
|
return fmt.Errorf("delete: %w", err)
|
|
}
|
|
fmt.Printf("Deleted @%s/%s\n", core.PrivateScopeSlug, name)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&assumeYes, "yes", false, "Confirm the destructive action")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginDeleteVersionCmd() *cobra.Command {
|
|
var assumeYes bool
|
|
var version string
|
|
cmd := &cobra.Command{
|
|
Use: "delete-version <@private/name|name>",
|
|
Short: "Delete one version of a private plugin",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
cli, _, _, hc, err := resolveClient(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hc.ActiveAccountID == "" {
|
|
return fmt.Errorf("no active account; run `ninja account set <slug>`")
|
|
}
|
|
if version == "" {
|
|
return fmt.Errorf("--version is required")
|
|
}
|
|
name, err := parsePrivateCoord(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !assumeYes {
|
|
return fmt.Errorf("refusing to delete without --yes")
|
|
}
|
|
_, err = cli.Reg.DeletePrivatePluginVersion(context.Background(),
|
|
connect.NewRequest(&v1.DeletePrivatePluginVersionRequest{
|
|
AccountId: hc.ActiveAccountID,
|
|
PluginName: name,
|
|
Version: version,
|
|
}))
|
|
if err != nil {
|
|
return fmt.Errorf("delete-version: %w", err)
|
|
}
|
|
fmt.Printf("Deleted @%s/%s@%s\n", core.PrivateScopeSlug, name, version)
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&assumeYes, "yes", false, "Confirm the destructive action")
|
|
cmd.Flags().StringVar(&version, "version", "", "Version to delete (required)")
|
|
return cmd
|
|
}
|
|
|
|
// visibilityLabel converts the proto PluginVisibility enum into the
|
|
// lowercase, user-facing form ("private", "public", ...). Kept here in the
|
|
// CLI rather than in core/plugin so that external consumers of core/plugin
|
|
// don't transitively pull in core/internal/api/... — every such pull-in
|
|
// races with the orchestrator's own generated bindings of the same proto
|
|
// file at proto-registration time.
|
|
func visibilityLabel(v v1.PluginVisibility) string {
|
|
switch v {
|
|
case v1.PluginVisibility_PLUGIN_VISIBILITY_PRIVATE:
|
|
return "private"
|
|
case v1.PluginVisibility_PLUGIN_VISIBILITY_UNDER_REVIEW:
|
|
return "under_review"
|
|
case v1.PluginVisibility_PLUGIN_VISIBILITY_PUBLIC,
|
|
v1.PluginVisibility_PLUGIN_VISIBILITY_UNSPECIFIED:
|
|
return "public"
|
|
case v1.PluginVisibility_PLUGIN_VISIBILITY_REJECTED:
|
|
return "rejected"
|
|
case v1.PluginVisibility_PLUGIN_VISIBILITY_TAKEN_DOWN:
|
|
return "taken_down"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// parsePrivateCoord accepts either a bare plugin name ("myplugin") or the
|
|
// canonical "@private/name" form and returns just the plugin name. Any other
|
|
// scope is rejected to make accidental deletion of public plugins impossible.
|
|
func parsePrivateCoord(s string) (string, error) {
|
|
s = strings.TrimSpace(s)
|
|
if !strings.HasPrefix(s, "@") {
|
|
return s, nil
|
|
}
|
|
rest := strings.TrimPrefix(s, "@")
|
|
before, after, ok := strings.Cut(rest, "/")
|
|
if !ok {
|
|
return "", fmt.Errorf("expected @%s/<name>, got %q", core.PrivateScopeSlug, s)
|
|
}
|
|
if before != core.PrivateScopeSlug {
|
|
return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, before)
|
|
}
|
|
return after, nil
|
|
}
|
|
|
|
func printPrivateSection(ctx context.Context, cli *orchclient.Client, hc creds.HostCreds) {
|
|
if hc.ActiveAccountID == "" {
|
|
fmt.Println("\nPrivate — (no active account; set one with `ninja account set <slug>`)")
|
|
return
|
|
}
|
|
fmt.Printf("\nPrivate — account: %s\n", hc.ActiveAccountSlug)
|
|
resp, err := cli.Reg.ListPrivatePlugins(ctx, connect.NewRequest(&v1.ListPrivatePluginsRequest{
|
|
AccountId: hc.ActiveAccountID,
|
|
}))
|
|
if err != nil {
|
|
fmt.Printf(" (error: %v)\n", err)
|
|
return
|
|
}
|
|
if len(resp.Msg.Plugins) == 0 {
|
|
fmt.Println(" (none — publish one with `ninja plugin publish --private`)")
|
|
return
|
|
}
|
|
for _, p := range resp.Msg.Plugins {
|
|
latest := p.ChannelVersions["latest"]
|
|
if latest == "" {
|
|
latest = "(no versions)"
|
|
}
|
|
fmt.Printf(" @%s/%s %s\n", core.PrivateScopeSlug, p.Plugin.Name, latest)
|
|
}
|
|
}
|
|
|
|
func newPluginInitCmd() *cobra.Command {
|
|
var scope, name string
|
|
var private bool
|
|
cmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Create a plugin in the registry and add a git remote",
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
host, _ := c.Flags().GetString("host")
|
|
cr, err := creds.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolvedHost, hc, err := cr.Resolve(host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cli := orchclient.New(resolvedHost, hc.Token)
|
|
ctx := context.Background()
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
|
|
// Read existing plugin.mod (if any) so prompts can default to its
|
|
// values. Missing or unparseable file is fine — we just start from
|
|
// empty defaults.
|
|
existing := &core.ModFile{}
|
|
if data, err := os.ReadFile("plugin.mod"); err == nil {
|
|
if m, err := core.ParseModFull(data); err == nil && m != nil {
|
|
existing = m
|
|
}
|
|
}
|
|
|
|
// --private forces the plugin into the account-scoped @private
|
|
// namespace and skips scope selection entirely. Inherit a previous
|
|
// `private = true` from the mod file so re-running init in a
|
|
// private plugin's directory doesn't silently flip it public.
|
|
if existing.Plugin.Private {
|
|
private = true
|
|
}
|
|
|
|
switch {
|
|
case private:
|
|
if hc.ActiveAccountID == "" {
|
|
return fmt.Errorf("--private requires an active account; run `ninja login` or `ninja account set <slug>`")
|
|
}
|
|
scope = "@" + core.PrivateScopeSlug
|
|
case scope != "":
|
|
scope, err = parseScope(scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case existing.Plugin.Scope != "":
|
|
scope = "@" + strings.TrimPrefix(existing.Plugin.Scope, "@")
|
|
default:
|
|
// Scope precedence: --scope flag > plugin.mod scope > interactive prompt.
|
|
scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Name precedence: --name flag > plugin.mod name > prompt.
|
|
if name == "" {
|
|
if existing.Plugin.Name != "" {
|
|
fmt.Printf("Plugin name [%s]: ", existing.Plugin.Name)
|
|
} else {
|
|
fmt.Print("Plugin name: ")
|
|
}
|
|
if !scanner.Scan() {
|
|
return fmt.Errorf("cancelled")
|
|
}
|
|
input := strings.TrimSpace(scanner.Text())
|
|
if input != "" {
|
|
name = input
|
|
} else {
|
|
name = existing.Plugin.Name
|
|
}
|
|
if name == "" {
|
|
return fmt.Errorf("plugin name is required")
|
|
}
|
|
}
|
|
rawName := name
|
|
name = strings.ToLower(name)
|
|
|
|
displayDefault := existing.Plugin.DisplayName
|
|
if displayDefault == "" {
|
|
displayDefault = rawName
|
|
}
|
|
fmt.Printf("Display name [%s]: ", displayDefault)
|
|
displayName := displayDefault
|
|
if scanner.Scan() {
|
|
if v := strings.TrimSpace(scanner.Text()); v != "" {
|
|
displayName = v
|
|
}
|
|
}
|
|
|
|
descLabel := "Description (one-line summary, optional)"
|
|
if existing.Plugin.Description != "" {
|
|
descLabel = fmt.Sprintf("Description [%s]", truncate(existing.Plugin.Description, 60))
|
|
}
|
|
fmt.Printf("%s: ", descLabel)
|
|
description := existing.Plugin.Description
|
|
if scanner.Scan() {
|
|
if v := strings.TrimSpace(scanner.Text()); v != "" {
|
|
description = v
|
|
}
|
|
}
|
|
|
|
kind, err := promptKindWithDefault(scanner, existing.Plugin.Kind)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var cats []string
|
|
if kind == "plugin" {
|
|
cats, err = promptCategoriesWithDefault(ctx, cli, scanner, existing.Plugin.Categories)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
tags, err := promptTagsWithDefault(ctx, cli, scanner, kind, existing.Plugin.Tags)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
createReq := &v1.CreatePluginRequest{
|
|
ScopeSlug: scopeAPISlug(scope),
|
|
Name: name,
|
|
DisplayName: displayName,
|
|
Description: description,
|
|
Kind: kind,
|
|
Categories: cats,
|
|
}
|
|
if private {
|
|
createReq.Visibility = v1.PluginVisibility_PLUGIN_VISIBILITY_PRIVATE
|
|
createReq.ActiveAccountId = hc.ActiveAccountID
|
|
}
|
|
if _, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(createReq)); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("\nCreated %s/%s\n", scope, name)
|
|
|
|
// For private plugins the scope line in plugin.mod is informational
|
|
// only — coord resolution always rewrites to @private. Persist an
|
|
// empty scope so future re-runs don't see a misleading scope value.
|
|
modScope := scope
|
|
if private {
|
|
modScope = ""
|
|
}
|
|
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, tags, private); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("plugin.mod updated")
|
|
|
|
if _, err := os.Stat(".git"); err == nil {
|
|
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err)
|
|
}
|
|
} else {
|
|
fmt.Println("Not in a git repo - run `git init && git commit` before `ninja plugin publish`")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)")
|
|
cmd.Flags().StringVar(&name, "name", "", "Plugin name")
|
|
cmd.Flags().BoolVar(&private, "private", false, "Create a private plugin under your active account's @private namespace")
|
|
return cmd
|
|
}
|
|
|
|
func promptScope(ctx context.Context, cli *orchclient.Client, cr *creds.Credentials, host string, hc creds.HostCreds, scanner *bufio.Scanner) (string, error) {
|
|
if hc.DefaultScope != "" {
|
|
fmt.Printf("Using default scope: %s (override with --scope)\n", hc.DefaultScope)
|
|
return hc.DefaultScope, nil
|
|
}
|
|
|
|
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
|
|
if err != nil {
|
|
return "", fmt.Errorf("listing scopes: %w", err)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("A scope is your organisation or personal namespace for plugins (like @mycompany).")
|
|
fmt.Println()
|
|
|
|
var scope string
|
|
if len(scopes.Msg.Scopes) == 0 {
|
|
fmt.Println("You don't have any scopes yet. Let's create one.")
|
|
fmt.Println()
|
|
s, err := createScopeInline(ctx, cli, scanner)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
scope = s
|
|
} else {
|
|
fmt.Println("Your scopes:")
|
|
for i, s := range scopes.Msg.Scopes {
|
|
fmt.Printf(" %d. @%s — %s\n", i+1, s.Slug, s.DisplayName)
|
|
}
|
|
fmt.Println()
|
|
fmt.Print("Select a scope [1]: ")
|
|
if !scanner.Scan() {
|
|
return "", fmt.Errorf("cancelled")
|
|
}
|
|
input := strings.TrimSpace(scanner.Text())
|
|
|
|
if input == "" {
|
|
scope = "@" + scopes.Msg.Scopes[0].Slug
|
|
} else if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) {
|
|
scope = "@" + scopes.Msg.Scopes[n-1].Slug
|
|
} else {
|
|
return "", fmt.Errorf("invalid selection: %s", input)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nSave %s as your default scope? [Y/n]: ", scope)
|
|
if scanner.Scan() {
|
|
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
|
if ans == "" || ans == "y" || ans == "yes" {
|
|
hc.DefaultScope = scope
|
|
cr.Hosts[host] = hc
|
|
if err := cr.Save(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err)
|
|
} else {
|
|
fmt.Println("Default scope saved.")
|
|
}
|
|
}
|
|
}
|
|
|
|
return scope, nil
|
|
}
|
|
|
|
func createScopeInline(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner) (string, error) {
|
|
fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).")
|
|
fmt.Println("It appears in plugin names like @acme/my-plugin.")
|
|
fmt.Println()
|
|
fmt.Print("Scope slug (lowercase letters, numbers, dashes): ")
|
|
if !scanner.Scan() {
|
|
return "", fmt.Errorf("cancelled")
|
|
}
|
|
slug, err := parseScope(scanner.Text())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
|
|
if !scanner.Scan() {
|
|
return "", fmt.Errorf("cancelled")
|
|
}
|
|
displayName := strings.TrimSpace(scanner.Text())
|
|
if displayName == "" {
|
|
displayName = scopeAPISlug(slug)
|
|
}
|
|
|
|
_, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{
|
|
Slug: scopeAPISlug(slug),
|
|
DisplayName: displayName,
|
|
}))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Printf("Created scope %s\n\n", slug)
|
|
return slug, nil
|
|
}
|
|
|
|
// promptKindWithDefault prompts for plugin kind. When current is "plugin" or
|
|
// "theme", that becomes the empty-input default (and the [n] hint reflects it).
|
|
func promptKindWithDefault(scanner *bufio.Scanner, current string) (string, error) {
|
|
defaultIdx := "1"
|
|
switch current {
|
|
case "theme":
|
|
defaultIdx = "2"
|
|
case "plugin", "":
|
|
defaultIdx = "1"
|
|
}
|
|
fmt.Println("Kind: 1) plugin 2) theme")
|
|
fmt.Printf("Select [%s]: ", defaultIdx)
|
|
if !scanner.Scan() {
|
|
return "", fmt.Errorf("cancelled")
|
|
}
|
|
v := strings.TrimSpace(scanner.Text())
|
|
if v == "" {
|
|
v = defaultIdx
|
|
}
|
|
switch v {
|
|
case "1", "plugin":
|
|
return "plugin", nil
|
|
case "2", "theme":
|
|
return "theme", nil
|
|
default:
|
|
return "", fmt.Errorf("invalid kind: %s", v)
|
|
}
|
|
}
|
|
|
|
// truncate clips a string to at most max runes, appending "…" if it was cut.
|
|
// Used to keep prompt labels readable when the existing description is long.
|
|
func truncate(s string, max int) string {
|
|
if len([]rune(s)) <= max {
|
|
return s
|
|
}
|
|
r := []rune(s)
|
|
return string(r[:max-1]) + "…"
|
|
}
|
|
|
|
// promptCategoriesWithDefault lists the canonical categories and lets the user
|
|
// pick by number. When current is non-empty, those slugs are pre-selected:
|
|
// the prompt shows their numbers as the default, and empty input keeps them.
|
|
func promptCategoriesWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, current []string) ([]string, error) {
|
|
resp, err := cli.Reg.ListCategories(ctx, connect.NewRequest(&v1.ListCategoriesRequest{}))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list categories: %w", err)
|
|
}
|
|
cats := resp.Msg.Categories
|
|
if len(cats) == 0 {
|
|
return nil, nil
|
|
}
|
|
// Build the default indices string from `current` so the prompt shows e.g.
|
|
// "Select [9]: " when current is ["templates"] and templates is the 9th
|
|
// entry.
|
|
slugToIdx := make(map[string]int, len(cats))
|
|
for i, c := range cats {
|
|
slugToIdx[c.Slug] = i + 1
|
|
}
|
|
var defaultNums []string
|
|
for _, slug := range current {
|
|
if i, ok := slugToIdx[slug]; ok {
|
|
defaultNums = append(defaultNums, strconv.Itoa(i))
|
|
}
|
|
}
|
|
defaultLabel := strings.Join(defaultNums, ",")
|
|
|
|
fmt.Println("Categories (comma-separated numbers, or blank to keep current):")
|
|
for i, c := range cats {
|
|
marker := " "
|
|
if _, picked := pickedSet(current)[c.Slug]; picked {
|
|
marker = "*"
|
|
}
|
|
fmt.Printf(" %d.%s%s — %s\n", i+1, marker, c.Slug, c.DisplayName)
|
|
}
|
|
if defaultLabel != "" {
|
|
fmt.Printf("Select [%s]: ", defaultLabel)
|
|
} else {
|
|
fmt.Print("Select: ")
|
|
}
|
|
if !scanner.Scan() {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
raw := strings.TrimSpace(scanner.Text())
|
|
if raw == "" {
|
|
// Empty input → keep current (or none, if there's no current).
|
|
return current, nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
n, err := strconv.Atoi(strings.TrimSpace(p))
|
|
if err != nil || n < 1 || n > len(cats) {
|
|
return nil, fmt.Errorf("invalid category selection: %s", p)
|
|
}
|
|
out = append(out, cats[n-1].Slug)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// fetchPopularTagsForPrompt returns a comma-joined "tag (count)" string of the
|
|
// top tags for the given kind, or "" if the orchestrator lookup fails (e.g.
|
|
// offline, unauthenticated, RPC error). Callers must tolerate an empty return
|
|
// and surface their own user-facing fallback message.
|
|
func fetchPopularTagsForPrompt(ctx context.Context, cli *orchclient.Client, kind string) string {
|
|
resp, err := cli.Reg.ListTags(ctx, connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20}))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if len(resp.Msg.Tags) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(resp.Msg.Tags))
|
|
for _, t := range resp.Msg.Tags {
|
|
parts = append(parts, fmt.Sprintf("%s (%d)", t.Tag, t.Count))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// promptTagsWithDefault prompts the user for free-form tags. Best-effort fetches
|
|
// the top-20 most-used tags via ListTags(kind) to surface popular suggestions;
|
|
// if that call fails (offline, etc.) it falls back to a plain prompt with a
|
|
// one-line warning. Empty input keeps `current`. Validates with NormalizeTags;
|
|
// on error, prints the issue and reprompts (one re-try, then bails).
|
|
func promptTagsWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, kind string, current []string) ([]string, error) {
|
|
popular := fetchPopularTagsForPrompt(ctx, cli, kind)
|
|
|
|
for attempt := range 2 {
|
|
if len(current) > 0 {
|
|
fmt.Printf("Tags (current: %s)\n", strings.Join(current, ", "))
|
|
} else {
|
|
fmt.Println("Tags (current: none)")
|
|
}
|
|
if popular != "" {
|
|
fmt.Printf("Popular: %s\n", popular)
|
|
} else if attempt == 0 {
|
|
fmt.Println("(could not fetch popular tags — offline or unauthenticated)")
|
|
}
|
|
fmt.Print("Enter comma-separated tags (blank to keep current): ")
|
|
if !scanner.Scan() {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
raw := strings.TrimSpace(scanner.Text())
|
|
if raw == "" {
|
|
return current, nil
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
out, err := core.NormalizeTags(parts)
|
|
if err == nil {
|
|
return out, nil
|
|
}
|
|
fmt.Printf(" %s\n", err.Error())
|
|
if attempt == 0 {
|
|
fmt.Println(" Try again:")
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("tag input failed validation after 2 attempts")
|
|
}
|
|
|
|
// pickedSet returns a set of the given slugs for O(1) membership checks.
|
|
func pickedSet(slugs []string) map[string]struct{} {
|
|
s := make(map[string]struct{}, len(slugs))
|
|
for _, x := range slugs {
|
|
s[x] = struct{}{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func newPluginPublishCmd() *cobra.Command {
|
|
var channel string
|
|
var strict bool
|
|
var privateFlag bool
|
|
var bnpFile string
|
|
cmd := &cobra.Command{
|
|
Use: "publish",
|
|
Short: "Build the .bnp artifact and publish it to the registry",
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
host, _ := c.Flags().GetString("host")
|
|
cr, err := creds.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolvedHost, hc, err := cr.Resolve(host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cli := orchclient.New(resolvedHost, hc.Token)
|
|
|
|
modBytes, err := os.ReadFile("plugin.mod")
|
|
if err != nil {
|
|
return fmt.Errorf("read plugin.mod: %w", err)
|
|
}
|
|
mod, err := core.ParseModFull(modBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// --private on the command line is sticky: persist it back to
|
|
// plugin.mod so the next publish keeps the visibility. The mod
|
|
// file is the source of truth at publish time; the flag is just
|
|
// the on-ramp.
|
|
private := mod.Plugin.Private || privateFlag
|
|
if private && !mod.Plugin.Private {
|
|
mod.Plugin.Private = true
|
|
if err := writeMod("plugin.mod", mod); err != nil {
|
|
return fmt.Errorf("persist private=true to plugin.mod: %w", err)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "plugin.mod updated: private = true")
|
|
}
|
|
|
|
switch {
|
|
case private:
|
|
if hc.ActiveAccountID == "" {
|
|
return fmt.Errorf("--private requires an active account; run `ninja login` or `ninja account set <slug>`")
|
|
}
|
|
if mod.Plugin.Scope != "" {
|
|
fmt.Fprintf(os.Stderr,
|
|
"warning: scope %q in plugin.mod is ignored because private = true; publishing as %s\n",
|
|
mod.Plugin.Scope, mod.Coords())
|
|
}
|
|
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
|
|
return fmt.Errorf("plugin.mod must have name and version")
|
|
}
|
|
default:
|
|
if mod.Plugin.Scope == "" || mod.Plugin.Name == "" || mod.Plugin.Version == "" {
|
|
return fmt.Errorf("plugin.mod must have scope, name, and version")
|
|
}
|
|
}
|
|
|
|
if err := checkRepoHasHEAD("."); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Backstop for the bump-then-publish flow when bump's auto-commit
|
|
// was skipped (--no-commit) or the version was hand-edited. Commits
|
|
// only plugin.mod; any other dirty paths are left for
|
|
// emitPublishWarnings (and ship via stash-create unless --strict).
|
|
if err := autoCommitPluginMod(fmt.Sprintf("bump to %s", mod.Plugin.Version)); err != nil {
|
|
return fmt.Errorf("auto-commit plugin.mod: %w", err)
|
|
}
|
|
|
|
if err := emitPublishWarnings(".", !strict, os.Stderr); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A .bnp artifact is the ONLY thing publish ships — instances
|
|
// reject source archives at install time, so the legacy
|
|
// source-archive path is gone. Default: build the artifact right
|
|
// here (same pipeline as `ninja plugin build`). --bnp skips the
|
|
// build and uploads a prebuilt artifact instead. The orchestrator
|
|
// verifies the .bnp layout/ABI/name/version server-side and
|
|
// records its abi_version.
|
|
var archiveBytes []byte
|
|
if bnpFile != "" {
|
|
archiveBytes, err = os.ReadFile(bnpFile)
|
|
if err != nil {
|
|
return fmt.Errorf("read --bnp file: %w", err)
|
|
}
|
|
} else {
|
|
isCodeless, err := bnp.IsCodelessRepo(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var res *bnp.BuildResult
|
|
if isCodeless {
|
|
res, err = bnp.BuildCodeless(context.Background(), bnp.BuildOptions{Dir: "."})
|
|
} else {
|
|
res, err = bnp.Build(context.Background(), bnp.BuildOptions{Dir: "."})
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("build .bnp: %w", err)
|
|
}
|
|
printBuildSummary(res)
|
|
// Mirror the orchestrator's verify gate (manifest version must
|
|
// match the published version when set) so the drift fails
|
|
// here, before the upload, with a fixable message.
|
|
if res.Version != "" && res.Version != mod.Plugin.Version {
|
|
return fmt.Errorf("manifest version %s != plugin.mod version %s (sync Registration.Version with plugin.mod)", res.Version, mod.Plugin.Version)
|
|
}
|
|
archiveBytes, err = os.ReadFile(res.OutputPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read built artifact: %w", err)
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
getReq := &v1.GetPluginRequest{
|
|
ScopeSlug: mod.Plugin.Scope,
|
|
Name: mod.Plugin.Name,
|
|
}
|
|
if private {
|
|
getReq.ScopeSlug = core.PrivateScopeSlug
|
|
getReq.ActiveAccountId = hc.ActiveAccountID
|
|
}
|
|
pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(getReq))
|
|
if err != nil {
|
|
return fmt.Errorf("get plugin: %w", err)
|
|
}
|
|
|
|
readme, _ := os.ReadFile("README.md")
|
|
changelog, _ := os.ReadFile("CHANGELOG.md")
|
|
|
|
pubResp, err := cli.Pub.PublishVersion(ctx, connect.NewRequest(&v1.PublishVersionRequest{
|
|
PluginId: pr.Msg.Plugin.Id,
|
|
Version: mod.Plugin.Version,
|
|
Channel: channel,
|
|
Archive: archiveBytes,
|
|
ReadmeMd: string(readme),
|
|
ChangelogMd: string(changelog),
|
|
}))
|
|
if err != nil {
|
|
return fmt.Errorf("publish: %w", err)
|
|
}
|
|
fmt.Printf("Published %s (.bnp artifact, %d bytes)\n", mod.Coords(), len(archiveBytes))
|
|
for _, w := range pubResp.Msg.Warnings {
|
|
fmt.Printf(" warning: %s\n", w)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version")
|
|
cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: warn and build from the working directory)")
|
|
cmd.Flags().BoolVar(&privateFlag, "private", false, "Publish as a private plugin under your active account's @private namespace")
|
|
cmd.Flags().StringVar(&bnpFile, "bnp", "", "Upload a prebuilt .bnp artifact (from `ninja plugin build`) instead of building one")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginStatusCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "status",
|
|
Short: "List owned scopes and their plugins",
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
host, _ := c.Flags().GetString("host")
|
|
cr, err := creds.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolvedHost, hc, err := cr.Resolve(host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cli := orchclient.New(resolvedHost, hc.Token)
|
|
ctx := context.Background()
|
|
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(scopes.Msg.Scopes) == 0 {
|
|
fmt.Println("No scopes yet. Create one with: ninja scope create")
|
|
return nil
|
|
}
|
|
for _, s := range scopes.Msg.Scopes {
|
|
fmt.Printf("@%s - %s\n", s.Slug, s.DisplayName)
|
|
gs, err := cli.Scope.GetScope(ctx, connect.NewRequest(&v1.GetScopeRequest{Slug: s.Slug}))
|
|
if err != nil {
|
|
fmt.Printf(" (error: %v)\n", err)
|
|
continue
|
|
}
|
|
for _, p := range gs.Msg.Plugins {
|
|
fmt.Printf(" @%s/%s [%s]\n", s.Slug, p.Name, visibilityLabel(p.Visibility))
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func newPluginBumpCmd() *cobra.Command {
|
|
var setVersion string
|
|
var noCommit bool
|
|
cmd := &cobra.Command{
|
|
Use: "bump [major|minor|patch]",
|
|
Short: "Bump the version in plugin.mod (default: patch)",
|
|
Long: "Updates plugin.mod with a new version and auto-commits it. Pass --no-commit to skip the commit.",
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(c *cobra.Command, args []string) error {
|
|
modBytes, err := os.ReadFile("plugin.mod")
|
|
if err != nil {
|
|
return fmt.Errorf("read plugin.mod: %w", err)
|
|
}
|
|
mod, err := core.ParseModFull(modBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mod.Plugin.Version == "" {
|
|
return fmt.Errorf("plugin.mod has no version")
|
|
}
|
|
old := mod.Plugin.Version
|
|
|
|
var next string
|
|
if setVersion != "" {
|
|
if len(args) > 0 {
|
|
return fmt.Errorf("cannot combine --set with bump argument")
|
|
}
|
|
if _, _, _, err := core.ParseBaseSemver(setVersion); err != nil {
|
|
return err
|
|
}
|
|
next = setVersion
|
|
} else {
|
|
level := "patch"
|
|
if len(args) > 0 {
|
|
level = args[0]
|
|
}
|
|
next, err = core.BumpVersion(old, level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
mod.Plugin.Version = next
|
|
if err := writeMod("plugin.mod", mod); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s -> %s\n", old, next)
|
|
|
|
if !noCommit {
|
|
if _, err := os.Stat(".git"); err == nil {
|
|
if err := autoCommitPluginMod(fmt.Sprintf("bump to %s", next)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&setVersion, "set", "", "Set explicit version (e.g. 0.5.0)")
|
|
cmd.Flags().BoolVar(&noCommit, "no-commit", false, "Don't auto-commit plugin.mod after bumping")
|
|
return cmd
|
|
}
|
|
|
|
func newPluginVersionCmd() *cobra.Command {
|
|
var short bool
|
|
cmd := &cobra.Command{
|
|
Use: "version",
|
|
Short: "Show local plugin version and registry channels",
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
modBytes, err := os.ReadFile("plugin.mod")
|
|
if err != nil {
|
|
return fmt.Errorf("read plugin.mod: %w", err)
|
|
}
|
|
mod, err := core.ParseModFull(modBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if short {
|
|
fmt.Println(mod.Plugin.Version)
|
|
return nil
|
|
}
|
|
|
|
local := mod.Plugin.Version
|
|
if local == "" {
|
|
local = "(unset)"
|
|
}
|
|
fmt.Printf("local: %s\n", local)
|
|
|
|
host, _ := c.Flags().GetString("host")
|
|
cr, err := creds.Load()
|
|
if err != nil {
|
|
fmt.Printf("(registry: %v)\n", err)
|
|
return nil
|
|
}
|
|
resolvedHost, hc, err := cr.Resolve(host)
|
|
if err != nil {
|
|
fmt.Printf("(registry: %v)\n", err)
|
|
return nil
|
|
}
|
|
if mod.Plugin.Scope == "" || mod.Plugin.Name == "" {
|
|
fmt.Println("(registry: plugin.mod missing scope or name)")
|
|
return nil
|
|
}
|
|
|
|
cli := orchclient.New(resolvedHost, hc.Token)
|
|
pr, err := cli.Reg.GetPlugin(context.Background(), connect.NewRequest(&v1.GetPluginRequest{
|
|
ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name,
|
|
}))
|
|
if err != nil {
|
|
fmt.Printf("(registry: %v)\n", err)
|
|
return nil
|
|
}
|
|
|
|
verByString := make(map[string]*v1.Version, len(pr.Msg.Versions))
|
|
for _, v := range pr.Msg.Versions {
|
|
verByString[v.Version] = v
|
|
}
|
|
|
|
names := make([]string, 0, len(pr.Msg.Channels))
|
|
for ch := range pr.Msg.Channels {
|
|
names = append(names, ch)
|
|
}
|
|
sort.Slice(names, func(i, j int) bool {
|
|
if names[i] == "latest" {
|
|
return true
|
|
}
|
|
if names[j] == "latest" {
|
|
return false
|
|
}
|
|
return names[i] < names[j]
|
|
})
|
|
for _, ch := range names {
|
|
ver := pr.Msg.Channels[ch]
|
|
date := ""
|
|
if v, ok := verByString[ver]; ok && v.PublishedAt != nil {
|
|
date = " (published " + v.PublishedAt.AsTime().Format("2006-01-02") + ")"
|
|
}
|
|
fmt.Printf("%-8s %s%s\n", ch+":", ver, date)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
cmd.Flags().BoolVar(&short, "short", false, "Print only the local version")
|
|
return cmd
|
|
}
|
|
|
|
var scopeSlugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{2,}$`)
|
|
|
|
func parseScope(input string) (string, error) {
|
|
raw := strings.TrimPrefix(strings.TrimSpace(input), "@")
|
|
if raw == "" {
|
|
return "", fmt.Errorf("scope is required")
|
|
}
|
|
if !scopeSlugRe.MatchString(raw) {
|
|
return "", fmt.Errorf("invalid scope %q: must be at least 3 characters, lowercase letters, numbers, and dashes", raw)
|
|
}
|
|
return "@" + raw, nil
|
|
}
|
|
|
|
func scopeAPISlug(scope string) string {
|
|
return strings.TrimPrefix(scope, "@")
|
|
}
|
|
|
|
func runCmd(name string, args ...string) error {
|
|
c := exec.Command(name, args...)
|
|
c.Stderr = os.Stderr
|
|
return c.Run()
|
|
}
|
|
|
|
// checkRepoHasHEAD returns a friendlier error than the raw git failure when
|
|
// the publish flow is invoked in a freshly-initialised repository that has
|
|
// no commits yet (the auto-commit of plugin.mod and the dirty-tree check
|
|
// both need a HEAD to diff against).
|
|
func checkRepoHasHEAD(repoDir string) error {
|
|
cmd := exec.Command("git", "rev-parse", "--verify", "HEAD")
|
|
cmd.Dir = repoDir
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("no commits in repository; run `git add . && git commit` before publishing")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error {
|
|
const file = "plugin.mod"
|
|
existing, _ := os.ReadFile(file)
|
|
mod, _ := core.ParseModFull(existing)
|
|
if mod == nil {
|
|
mod = &core.ModFile{}
|
|
}
|
|
if mod.Plugin.Version == "" {
|
|
mod.Plugin.Version = "0.1.0"
|
|
}
|
|
mod.Plugin.Scope = scope
|
|
mod.Plugin.Name = name
|
|
mod.Plugin.DisplayName = displayName
|
|
mod.Plugin.Description = description
|
|
mod.Plugin.Kind = kind
|
|
mod.Plugin.Categories = categories
|
|
mod.Plugin.Tags = tags
|
|
mod.Plugin.Private = private
|
|
return writeMod(file, mod)
|
|
}
|
|
|
|
func writeMod(path string, m *core.ModFile) error {
|
|
var b strings.Builder
|
|
b.WriteString("[plugin]\n")
|
|
fmt.Fprintf(&b, "name = %q\n", m.Plugin.Name)
|
|
if m.Plugin.DisplayName != "" {
|
|
fmt.Fprintf(&b, "display_name = %q\n", m.Plugin.DisplayName)
|
|
}
|
|
fmt.Fprintf(&b, "scope = %q\n", m.Plugin.Scope)
|
|
fmt.Fprintf(&b, "version = %q\n", m.Plugin.Version)
|
|
if m.Plugin.Description != "" {
|
|
fmt.Fprintf(&b, "description = %q\n", m.Plugin.Description)
|
|
}
|
|
if m.Plugin.Kind != "" {
|
|
fmt.Fprintf(&b, "kind = %q\n", m.Plugin.Kind)
|
|
}
|
|
if len(m.Plugin.Categories) > 0 {
|
|
quoted := make([]string, len(m.Plugin.Categories))
|
|
for i, c := range m.Plugin.Categories {
|
|
quoted[i] = fmt.Sprintf("%q", c)
|
|
}
|
|
fmt.Fprintf(&b, "categories = [%s]\n", strings.Join(quoted, ", "))
|
|
}
|
|
if len(m.Plugin.Tags) > 0 {
|
|
quoted := make([]string, len(m.Plugin.Tags))
|
|
for i, t := range m.Plugin.Tags {
|
|
quoted[i] = fmt.Sprintf("%q", t)
|
|
}
|
|
fmt.Fprintf(&b, "tags = [%s]\n", strings.Join(quoted, ", "))
|
|
}
|
|
if m.Plugin.Private {
|
|
b.WriteString("private = true\n")
|
|
}
|
|
if m.Plugin.DataDir {
|
|
b.WriteString("data_dir = true\n")
|
|
}
|
|
if m.Compatibility != nil {
|
|
b.WriteString("\n[compatibility]\n")
|
|
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
|
|
}
|
|
for _, r := range m.Requires {
|
|
b.WriteString("\n[[requires]]\n")
|
|
fmt.Fprintf(&b, "name = %q\n", r.Name)
|
|
fmt.Fprintf(&b, "version = %q\n", r.Version)
|
|
}
|
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
|
}
|
|
|
|
// emitPublishWarnings gates the publish on working-tree state. The .bnp is
|
|
// built from the working directory while the version tag and registry
|
|
// metadata point at commits — a dirty tree means the published artifact may
|
|
// not match the tagged source.
|
|
//
|
|
// - allowDirty=false (--strict): a dirty tree aborts the publish.
|
|
// - allowDirty=true (the default): a dirty tree emits a drift warning
|
|
// listing the changed/untracked paths, then the publish proceeds.
|
|
func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error {
|
|
cmd := exec.Command("git", "status", "--porcelain")
|
|
cmd.Dir = repoDir
|
|
out, _ := cmd.Output()
|
|
dirty := strings.TrimSpace(string(out))
|
|
if dirty == "" {
|
|
return nil
|
|
}
|
|
if !allowDirty {
|
|
return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict")
|
|
}
|
|
_, _ = fmt.Fprintln(w, "warning: working tree differs from HEAD; the published .bnp is built from the working directory:")
|
|
for n := range strings.SplitSeq(dirty, "\n") {
|
|
_, _ = fmt.Fprintln(w, " "+n)
|
|
}
|
|
_, _ = fmt.Fprintln(w, " (commit before publishing so the artifact matches the tagged source)")
|
|
return nil
|
|
}
|
|
|
|
// autoCommitPluginMod stages and commits plugin.mod with the given message,
|
|
// if plugin.mod differs from HEAD. No-op when there's nothing to commit.
|
|
//
|
|
// The commit is path-scoped (`git commit -- plugin.mod`) so any other staged
|
|
// paths in the index are left alone — important now that publish calls this
|
|
// automatically.
|
|
func autoCommitPluginMod(message string) error {
|
|
out, err := exec.Command("git", "status", "--porcelain", "plugin.mod").Output()
|
|
if err != nil {
|
|
return fmt.Errorf("git status: %w", err)
|
|
}
|
|
if strings.TrimSpace(string(out)) == "" {
|
|
return nil
|
|
}
|
|
if err := runCmd("git", "add", "plugin.mod"); err != nil {
|
|
return err
|
|
}
|
|
if err := runCmd("git", "commit", "-m", message, "--", "plugin.mod"); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("Committed plugin.mod")
|
|
return nil
|
|
}
|