diff --git a/cmd/ninja/cmd/account.go b/cmd/ninja/cmd/account.go new file mode 100644 index 0000000..229d167 --- /dev/null +++ b/cmd/ninja/cmd/account.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "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 newAccountCmd() *cobra.Command { + c := &cobra.Command{ + Use: "account", + Short: "Manage which account ninja acts as", + Long: `Account-scoped commands like ` + "`ninja plugins publish --private`" + ` act +against an "active account" — the orchestrator-side account whose members +can see and install the plugin. The active account is selected at +` + "`ninja login`" + ` time and persisted in your credentials file.`, + } + c.AddCommand(newAccountListCmd(), newAccountSetCmd(), newAccountShowCmd()) + return c +} + +func newAccountListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List the accounts the authenticated user belongs to", + RunE: func(c *cobra.Command, _ []string) error { + cli, _, _, hc, err := resolveClient(c) + if err != nil { + return err + } + accts, err := cli.Auth.ListMyAccounts(context.Background(), + connect.NewRequest(&v1.ListMyAccountsRequest{})) + if err != nil { + return fmt.Errorf("list accounts: %w", err) + } + if len(accts.Msg.Accounts) == 0 { + fmt.Println("No accounts.") + return nil + } + for _, a := range accts.Msg.Accounts { + marker := " " + if a.Id == hc.ActiveAccountID { + marker = "* " + } + fmt.Printf("%s%s — %s (%s)\n", marker, a.Slug, a.DisplayName, a.Role) + } + return nil + }, + } +} + +func newAccountSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Change the active account", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + cli, cr, host, hc, err := resolveClient(c) + if err != nil { + return err + } + slug := strings.TrimPrefix(args[0], "@") + accts, err := cli.Auth.ListMyAccounts(context.Background(), + connect.NewRequest(&v1.ListMyAccountsRequest{})) + if err != nil { + return fmt.Errorf("list accounts: %w", err) + } + for _, a := range accts.Msg.Accounts { + if a.Slug == slug { + hc.ActiveAccountID = a.Id + hc.ActiveAccountSlug = a.Slug + cr.Hosts[host] = hc + if err := cr.Save(); err != nil { + return err + } + fmt.Printf("Active account: %s (%s)\n", a.Slug, a.DisplayName) + return nil + } + } + return fmt.Errorf("account %q not found among your memberships; try `ninja account list`", slug) + }, + } +} + +func newAccountShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Show the currently active account", + RunE: func(c *cobra.Command, _ []string) error { + _, _, _, hc, err := resolveClient(c) + if err != nil { + return err + } + if hc.ActiveAccountSlug == "" { + fmt.Println("(no active account set; run `ninja login` or `ninja account set `)") + return nil + } + fmt.Printf("Active account: %s (id=%s)\n", hc.ActiveAccountSlug, hc.ActiveAccountID) + return nil + }, + } +} + +// resolveClient is a small helper used by every `ninja account` subcommand: +// it loads creds, resolves the host, and returns an authed client plus the +// loaded credentials so the caller can persist changes. +func resolveClient(c *cobra.Command) (*orchclient.Client, *creds.Credentials, string, creds.HostCreds, error) { + host, _ := c.Flags().GetString("host") + cr, err := creds.Load() + if err != nil { + return nil, nil, "", creds.HostCreds{}, err + } + resolvedHost, hc, err := cr.Resolve(host) + if err != nil { + return nil, nil, "", creds.HostCreds{}, err + } + return orchclient.New(resolvedHost, hc.Token), cr, resolvedHost, hc, nil +} + +// pickAccountInteractive prompts the user to select an account by number from +// the given list and returns the chosen account. Used by `ninja login` when +// the user belongs to more than one account. +func pickAccountInteractive(scanner *bufio.Scanner, accounts []*v1.Account) (*v1.Account, error) { + if len(accounts) == 0 { + return nil, fmt.Errorf("no accounts available") + } + fmt.Println("Select an account:") + for i, a := range accounts { + fmt.Printf(" %d) %s — %s (%s)\n", i+1, a.Slug, a.DisplayName, a.Role) + } + fmt.Print("> ") + if !scanner.Scan() { + return nil, fmt.Errorf("cancelled") + } + v := strings.TrimSpace(scanner.Text()) + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > len(accounts) { + return nil, fmt.Errorf("invalid selection: %s", v) + } + return accounts[n-1], nil +} diff --git a/cmd/ninja/cmd/login.go b/cmd/ninja/cmd/login.go index c10166e..609087f 100644 --- a/cmd/ninja/cmd/login.go +++ b/cmd/ninja/cmd/login.go @@ -1,8 +1,10 @@ package cmd import ( + "bufio" "context" "fmt" + "os" "time" "connectrpc.com/connect" @@ -54,7 +56,12 @@ func newLoginCmd() *cobra.Command { if cr.Hosts == nil { cr.Hosts = map[string]creds.HostCreds{} } - cr.Hosts[host] = creds.HostCreds{Token: poll.Msg.AccessToken} + hc := creds.HostCreds{Token: poll.Msg.AccessToken} + authed := orchclient.New(host, hc.Token) + if err := selectActiveAccount(ctx, authed, &hc); err != nil { + return err + } + cr.Hosts[host] = hc if err := cr.Save(); err != nil { return err } @@ -96,6 +103,35 @@ func newWhoamiCmd() *cobra.Command { } } +// selectActiveAccount fetches the user's accounts and writes the active one +// into hc. With 0 accounts it errors (the server contract guarantees every +// user has at least one). With 1 it auto-selects silently. With ≥2 it +// prompts interactively on stdin. +func selectActiveAccount(ctx context.Context, cli *orchclient.Client, hc *creds.HostCreds) error { + resp, err := cli.Auth.ListMyAccounts(ctx, connect.NewRequest(&v1.ListMyAccountsRequest{})) + if err != nil { + return fmt.Errorf("list accounts: %w", err) + } + accts := resp.Msg.Accounts + switch len(accts) { + case 0: + return fmt.Errorf("no accounts found for this user; contact support") + case 1: + hc.ActiveAccountID = accts[0].Id + hc.ActiveAccountSlug = accts[0].Slug + fmt.Printf("Active account: %s (%s)\n", accts[0].Slug, accts[0].DisplayName) + return nil + } + chosen, err := pickAccountInteractive(bufio.NewScanner(os.Stdin), accts) + if err != nil { + return err + } + hc.ActiveAccountID = chosen.Id + hc.ActiveAccountSlug = chosen.Slug + fmt.Printf("Active account: %s (%s)\n", chosen.Slug, chosen.DisplayName) + return nil +} + func newLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout", diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go index 45b4a5e..947af06 100644 --- a/cmd/ninja/cmd/plugin.go +++ b/cmd/ninja/cmd/plugin.go @@ -29,14 +29,200 @@ func newPluginCmd() *cobra.Command { newPluginInitCmd(), newPluginPublishCmd(), newPluginStatusCmd(), + newPluginListCmd(), + newPluginDeleteCmd(), + newPluginDeleteVersionCmd(), newPluginBumpCmd(), newPluginVersionCmd(), ) 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, core.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 `") + } + 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 `") + } + 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 +} + +// 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, "@") + slash := strings.IndexByte(rest, '/') + if slash < 0 { + return "", fmt.Errorf("expected @%s/, got %q", core.PrivateScopeSlug, s) + } + if rest[:slash] != core.PrivateScopeSlug { + return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, rest[:slash]) + } + return rest[slash+1:], 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 `)") + 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", @@ -64,8 +250,20 @@ func newPluginInitCmd() *cobra.Command { } } - // Scope precedence: --scope flag > plugin.mod scope > interactive prompt. + // --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 `") + } + scope = "@" + core.PrivateScopeSlug case scope != "": scope, err = parseScope(scope) if err != nil { @@ -74,6 +272,7 @@ func newPluginInitCmd() *cobra.Command { 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 @@ -139,25 +338,37 @@ func newPluginInitCmd() *cobra.Command { } } - if _, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{ + createReq := &v1.CreatePluginRequest{ ScopeSlug: scopeAPISlug(scope), Name: name, DisplayName: displayName, Description: description, Kind: kind, Categories: cats, - })); err != nil { + } + 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) - if err := upsertPluginMod(scope, name, displayName, description, kind, cats); err != nil { + // 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, private); err != nil { return err } fmt.Println("plugin.mod updated") if _, err := os.Stat(".git"); err == nil { - if err := autoCommitPluginMod(); err != nil { + if err := autoCommitPluginMod("Add plugin.mod"); err != nil { fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err) } } else { @@ -168,6 +379,7 @@ func newPluginInitCmd() *cobra.Command { } 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 } @@ -376,7 +588,8 @@ func pickedSet(slugs []string) map[string]struct{} { func newPluginPublishCmd() *cobra.Command { var channel string - var allowDirty bool + var strict bool + var privateFlag bool cmd := &cobra.Command{ Use: "publish", Short: "Build a source archive and publish a new version to the registry", @@ -400,15 +613,52 @@ func newPluginPublishCmd() *cobra.Command { 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") + + // --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 `") + } + 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 } - if err := emitPublishWarnings(".", allowDirty, os.Stderr); err != nil { + // 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 } @@ -418,9 +668,15 @@ func newPluginPublishCmd() *cobra.Command { } ctx := context.Background() - pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(&v1.GetPluginRequest{ - ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name, - })) + 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) } @@ -447,7 +703,8 @@ func newPluginPublishCmd() *cobra.Command { }, } cmd.Flags().StringVar(&channel, "channel", "latest", "Channel to point at this version") - cmd.Flags().BoolVar(&allowDirty, "allow-dirty", false, "Skip clean-working-tree check") + cmd.Flags().BoolVar(&strict, "strict", false, "Fail if the working tree is dirty (default: ship dirty trees via stash-create)") + cmd.Flags().BoolVar(&privateFlag, "private", false, "Publish as a private plugin under your active account's @private namespace") return cmd } @@ -493,10 +750,11 @@ func newPluginStatusCmd() *cobra.Command { 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. Does not commit or tag.", + 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") @@ -537,10 +795,19 @@ func newPluginBumpCmd() *cobra.Command { 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 } @@ -685,7 +952,7 @@ func submodulePaths(repoDir string) []string { return paths } -func upsertPluginMod(scope, name, displayName, description, kind string, categories []string) error { +func upsertPluginMod(scope, name, displayName, description, kind string, categories []string, private bool) error { const file = "plugin.mod" existing, _ := os.ReadFile(file) mod, _ := core.ParseModFull(existing) @@ -701,6 +968,7 @@ func upsertPluginMod(scope, name, displayName, description, kind string, categor mod.Plugin.Description = description mod.Plugin.Kind = kind mod.Plugin.Categories = categories + mod.Plugin.Private = private return writeMod(file, mod) } @@ -726,6 +994,9 @@ func writeMod(path string, m *core.ModFile) error { } b.WriteString(fmt.Sprintf("categories = [%s]\n", strings.Join(quoted, ", "))) } + if m.Plugin.Private { + b.WriteString("private = true\n") + } if m.Compatibility != nil { b.WriteString("\n[compatibility]\n") b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore)) @@ -757,7 +1028,7 @@ func gitignoredTrackedWarning(repoDir string, w io.Writer) { } // untrackedFilesWarning writes a warning to w listing untracked files in -// repoDir. Used on the --allow-dirty publish path because `git stash create` +// repoDir. Fires on the dirty-tree 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) { @@ -768,7 +1039,7 @@ func untrackedFilesWarning(repoDir string, w io.Writer) { if names == "" { return } - fmt.Fprintln(w, "warning: --allow-dirty: these untracked files will NOT be in the archive:") + fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:") for _, n := range strings.Split(names, "\n") { fmt.Fprintln(w, " "+n) } @@ -779,12 +1050,12 @@ func untrackedFilesWarning(repoDir string, w io.Writer) { // CLI expects: // // 1. gitignoredTrackedWarning is unconditional so the developer sees it even -// when the publish is about to abort. -// 2. If allowDirty is false the working tree must be clean — a dirty tree -// returns an error and the remaining warnings (which are only useful on -// the proceeding-publish path) are skipped. -// 3. If allowDirty is true the untracked-files warning fires so the user -// knows those files won't be in the archive. +// when the publish is about to abort under --strict. +// 2. If allowDirty is false (i.e. --strict) the working tree must be clean — +// a dirty tree returns an error and the remaining warnings (which are +// only useful on the proceeding-publish path) are skipped. +// 3. If allowDirty is true (the default) the untracked-files warning fires +// so the user knows those files won't be in the archive. // 4. submoduleWarning is unconditional on the proceeding path because // `git archive` does not recurse into submodules. func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error { @@ -795,7 +1066,7 @@ func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error { cmd.Dir = repoDir out, _ := cmd.Output() if len(strings.TrimSpace(string(out))) > 0 { - return fmt.Errorf("working tree dirty; commit or pass --allow-dirty") + return fmt.Errorf("working tree dirty (--strict); commit your changes or drop --strict") } } else { // `git stash create` only captures tracked content, so untracked @@ -822,9 +1093,13 @@ func submoduleWarning(repoDir string, w io.Writer) { 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 { +// 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) @@ -835,7 +1110,7 @@ func autoCommitPluginMod() error { if err := runCmd("git", "add", "plugin.mod"); err != nil { return err } - if err := runCmd("git", "commit", "-m", "Add plugin.mod"); err != nil { + if err := runCmd("git", "commit", "-m", message, "--", "plugin.mod"); err != nil { return err } fmt.Println("Committed plugin.mod") diff --git a/cmd/ninja/cmd/plugin_test.go b/cmd/ninja/cmd/plugin_test.go index e86c3f5..9c16359 100644 --- a/cmd/ninja/cmd/plugin_test.go +++ b/cmd/ninja/cmd/plugin_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + core "git.dev.alexdunmow.com/block/core/plugin" ) func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) { @@ -54,7 +56,7 @@ func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) { } t.Chdir(dir) - if err := autoCommitPluginMod(); err != nil { + if err := autoCommitPluginMod("Add plugin.mod"); err != nil { t.Fatalf("autoCommitPluginMod: %v", err) } @@ -77,7 +79,7 @@ func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) { beforeSHA := gitHeadSHA(t, dir) t.Chdir(dir) - if err := autoCommitPluginMod(); err != nil { + if err := autoCommitPluginMod("Add plugin.mod"); err != nil { t.Fatalf("autoCommitPluginMod: %v", err) } @@ -105,7 +107,7 @@ func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) { } t.Chdir(dir) - if err := autoCommitPluginMod(); err != nil { + if err := autoCommitPluginMod("Add plugin.mod"); err != nil { t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err) } @@ -131,6 +133,77 @@ func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) { } } +func TestAutoCommitPluginMod_UsesProvidedMessage(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "README.md") + runGit(t, dir, "commit", "-qm", "init") + + if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), + []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(dir) + if err := autoCommitPluginMod("bump to 0.3.0"); err != nil { + t.Fatalf("autoCommitPluginMod: %v", err) + } + + if got := gitLogSubject(t, dir); got != "bump to 0.3.0" { + t.Errorf("expected commit subject 'bump to 0.3.0', got %q", got) + } +} + +func TestAutoCommitPluginMod_LeavesOtherStagedPathsAlone(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "README.md") + runGit(t, dir, "commit", "-qm", "init") + + // Stage an unrelated change that publish should NOT sweep up into the + // plugin.mod auto-commit. + if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("scratch"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, dir, "add", "other.txt") + + if err := os.WriteFile(filepath.Join(dir, "plugin.mod"), + []byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil { + t.Fatal(err) + } + + t.Chdir(dir) + if err := autoCommitPluginMod("bump to 0.3.0"); err != nil { + t.Fatalf("autoCommitPluginMod: %v", err) + } + + // The new commit should touch plugin.mod only. + filesCmd := exec.Command("git", "show", "--name-only", "--pretty=", "HEAD") + filesCmd.Dir = dir + filesOut, err := filesCmd.CombinedOutput() + if err != nil { + t.Fatalf("git show: %v\n%s", err, filesOut) + } + files := strings.Fields(strings.TrimSpace(string(filesOut))) + if len(files) != 1 || files[0] != "plugin.mod" { + t.Errorf("expected commit to touch only plugin.mod, got %v", files) + } + + // other.txt should still be staged (waiting for the developer to deal with). + statusCmd := exec.Command("git", "status", "--porcelain", "other.txt") + statusCmd.Dir = dir + statusOut, _ := statusCmd.Output() + if !strings.HasPrefix(strings.TrimSpace(string(statusOut)), "A ") { + t.Errorf("expected other.txt to remain staged ('A '), got %q", string(statusOut)) + } +} + func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-q") @@ -148,7 +221,7 @@ func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) { t.Chdir(dir) t.Setenv("PATH", "") - err := autoCommitPluginMod() + err := autoCommitPluginMod("Add plugin.mod") if err == nil { t.Fatal("expected error when git is missing from PATH, got nil") } @@ -381,7 +454,7 @@ func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) { if !strings.Contains(out, "scratch.txt") { t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out) } - if !strings.Contains(out, "--allow-dirty") { + if !strings.Contains(out, "NOT be in the archive") { t.Errorf("expected untracked-files warning fragment, got: %q", out) } }) @@ -395,12 +468,86 @@ func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) { if !strings.Contains(err.Error(), "working tree dirty") { t.Errorf("expected dirty-tree error, got: %v", err) } - if strings.Contains(buf.String(), "--allow-dirty: these untracked files") { + if !strings.Contains(err.Error(), "--strict") { + t.Errorf("expected dirty-tree error to reference --strict, got: %v", err) + } + if strings.Contains(buf.String(), "untracked files will NOT be in the archive") { t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String()) } }) } +func TestWriteMod_PrivateTrueSerializes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plugin.mod") + m := &core.ModFile{Plugin: core.ModPlugin{ + Name: "myplugin", + Scope: "themes", + Version: "0.1.0", + Private: true, + }} + if err := writeMod(path, m); err != nil { + t.Fatalf("writeMod: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !strings.Contains(string(got), "private = true") { + t.Errorf("expected `private = true` line in plugin.mod, got:\n%s", got) + } +} + +func TestWriteMod_PrivateFalseOmitted(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plugin.mod") + m := &core.ModFile{Plugin: core.ModPlugin{ + Name: "publicthing", + Scope: "themes", + Version: "0.1.0", + }} + if err := writeMod(path, m); err != nil { + t.Fatalf("writeMod: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if strings.Contains(string(got), "private") { + t.Errorf("expected no `private` line, got:\n%s", got) + } +} + +func TestParsePrivateCoord(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {in: "myplugin", want: "myplugin"}, + {in: "@private/myplugin", want: "myplugin"}, + {in: " myplugin ", want: "myplugin"}, + {in: "@themes/myplugin", wantErr: true}, + {in: "@private", wantErr: true}, + } + for _, c := range cases { + got, err := parsePrivateCoord(c.in) + if c.wantErr { + if err == nil { + t.Errorf("parsePrivateCoord(%q) = %q, want error", c.in, got) + } + continue + } + if err != nil { + t.Errorf("parsePrivateCoord(%q) err: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("parsePrivateCoord(%q) = %q, want %q", c.in, got, c.want) + } + } +} + func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) diff --git a/cmd/ninja/cmd/root.go b/cmd/ninja/cmd/root.go index e2a547d..d330b0a 100644 --- a/cmd/ninja/cmd/root.go +++ b/cmd/ninja/cmd/root.go @@ -15,5 +15,6 @@ func NewRoot() *cobra.Command { root.AddCommand(newWhoamiCmd()) root.AddCommand(newPluginCmd()) root.AddCommand(newScopeCmd()) + root.AddCommand(newAccountCmd()) return root } diff --git a/cmd/ninja/internal/creds/creds.go b/cmd/ninja/internal/creds/creds.go index e349af1..a9361dd 100644 --- a/cmd/ninja/internal/creds/creds.go +++ b/cmd/ninja/internal/creds/creds.go @@ -16,6 +16,16 @@ type HostCreds struct { Token string `json:"token"` User string `json:"user,omitempty"` DefaultScope string `json:"default_scope,omitempty"` + // ActiveAccountID is the orchestrator-side UUID of the account that + // account-scoped commands (notably `ninja plugins publish --private`) + // operate against. Set during `ninja login` (forced selection when the + // user belongs to more than one account) and changeable via + // `ninja account set`. + ActiveAccountID string `json:"active_account_id,omitempty"` + // ActiveAccountSlug mirrors ActiveAccountID in human-readable form for + // display in CLI output. The orchestrator-side slug is authoritative; + // the CLI refreshes it whenever it talks to the server. + ActiveAccountSlug string `json:"active_account_slug,omitempty"` } func filePath() (string, error) { diff --git a/cmd/ninja/internal/creds/creds_test.go b/cmd/ninja/internal/creds/creds_test.go new file mode 100644 index 0000000..b70c471 --- /dev/null +++ b/cmd/ninja/internal/creds/creds_test.go @@ -0,0 +1,45 @@ +package creds + +import ( + "encoding/json" + "testing" +) + +func TestHostCreds_ActiveAccountRoundTrip(t *testing.T) { + src := HostCreds{ + Token: "tok", + ActiveAccountID: "acct-uuid", + ActiveAccountSlug: "acme", + } + b, err := json.Marshal(src) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var got HostCreds + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.ActiveAccountID != "acct-uuid" { + t.Errorf("ActiveAccountID = %q, want acct-uuid", got.ActiveAccountID) + } + if got.ActiveAccountSlug != "acme" { + t.Errorf("ActiveAccountSlug = %q, want acme", got.ActiveAccountSlug) + } +} + +func TestHostCreds_LegacyFileLoadsWithoutAccount(t *testing.T) { + // A creds.json from before the active-account fields existed must still + // unmarshal cleanly; the new fields should be zero-valued. + legacy := `{"token":"tok","user":"alice","default_scope":"@themes"}` + var got HostCreds + if err := json.Unmarshal([]byte(legacy), &got); err != nil { + t.Fatalf("Unmarshal legacy: %v", err) + } + if got.Token != "tok" { + t.Errorf("Token = %q", got.Token) + } + if got.ActiveAccountID != "" || got.ActiveAccountSlug != "" { + t.Errorf("ActiveAccount* should be empty for legacy file, got id=%q slug=%q", + got.ActiveAccountID, got.ActiveAccountSlug) + } +} diff --git a/docs/superpowers/specs/2026-06-03-plugin-publish-and-categories-design.md b/docs/superpowers/specs/2026-06-03-plugin-publish-and-categories-design.md index efe8c5f..939ebf7 100644 --- a/docs/superpowers/specs/2026-06-03-plugin-publish-and-categories-design.md +++ b/docs/superpowers/specs/2026-06-03-plugin-publish-and-categories-design.md @@ -182,14 +182,28 @@ they can `publish` (`git archive` requires a repo). `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 +2. Auto-commit `plugin.mod` if it's dirty (path-scoped commit, so any other + staged paths are left alone). Backstop for the bump-then-publish flow + when `bump --no-commit` was used or the version was hand-edited. +3. Dirty-tree handling: + - Default: any remaining dirty paths ship via `git stash create` → + `git archive`. An untracked-files warning fires (those don't make it + into the archive). + - `--strict`: fail with a "working tree dirty (--strict)" error if any + dirty paths remain after the plugin.mod auto-commit. +4. 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, +5. `git archive --format=tar ` → zstd compress → bytes in memory + (treeish is `HEAD` for a clean tree, the stash-create sha for a dirty + one). +6. Optional README/CHANGELOG bytes. +7. Call `PublishVersion(plugin_id, version, channel, archive, readme, changelog)`. -7. Print version + warnings. +8. Print version + warnings. + +`ninja plugin bump` auto-commits its `plugin.mod` change with subject +`bump to X.Y.Z`. Pass `--no-commit` to skip the commit (publish's auto-commit +backstop will pick it up on the next publish). No tag is created. No git push. No `.git/config` writes anywhere in the flow. diff --git a/internal/api/orchestrator/v1/plugin_registry.pb.go b/internal/api/orchestrator/v1/plugin_registry.pb.go index 627df89..9071f65 100644 --- a/internal/api/orchestrator/v1/plugin_registry.pb.go +++ b/internal/api/orchestrator/v1/plugin_registry.pb.go @@ -1008,11 +1008,15 @@ func (x *CreatePluginResponse) GetPlugin() *Plugin { } type GetPluginRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ScopeSlug string `protobuf:"bytes,1,opt,name=scope_slug,json=scopeSlug,proto3" json:"scope_slug,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ScopeSlug string `protobuf:"bytes,1,opt,name=scope_slug,json=scopeSlug,proto3" json:"scope_slug,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // active_account_id is required when scope_slug = "private" so the server + // can resolve (account_id, name) instead of (scope_id, name). Ignored + // otherwise. + ActiveAccountId string `protobuf:"bytes,3,opt,name=active_account_id,json=activeAccountId,proto3" json:"active_account_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetPluginRequest) Reset() { @@ -1059,6 +1063,13 @@ func (x *GetPluginRequest) GetName() string { return "" } +func (x *GetPluginRequest) GetActiveAccountId() string { + if x != nil { + return x.ActiveAccountId + } + return "" +} + type GetPluginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Plugin *Plugin `protobuf:"bytes,1,opt,name=plugin,proto3" json:"plugin,omitempty"` @@ -3030,11 +3041,12 @@ const file_orchestrator_v1_plugin_registry_proto_rawDesc = "" + "visibility\x12*\n" + "\x11active_account_id\x18\b \x01(\tR\x0factiveAccountId\"G\n" + "\x14CreatePluginResponse\x12/\n" + - "\x06plugin\x18\x01 \x01(\v2\x17.orchestrator.v1.PluginR\x06plugin\"E\n" + + "\x06plugin\x18\x01 \x01(\v2\x17.orchestrator.v1.PluginR\x06plugin\"q\n" + "\x10GetPluginRequest\x12\x1d\n" + "\n" + "scope_slug\x18\x01 \x01(\tR\tscopeSlug\x12\x12\n" + - "\x04name\x18\x02 \x01(\tR\x04name\"\x85\x02\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12*\n" + + "\x11active_account_id\x18\x03 \x01(\tR\x0factiveAccountId\"\x85\x02\n" + "\x11GetPluginResponse\x12/\n" + "\x06plugin\x18\x01 \x01(\v2\x17.orchestrator.v1.PluginR\x06plugin\x124\n" + "\bversions\x18\x02 \x03(\v2\x18.orchestrator.v1.VersionR\bversions\x12L\n" + diff --git a/proto/orchestrator/v1/plugin_registry.proto b/proto/orchestrator/v1/plugin_registry.proto index d192e56..9b33b24 100644 --- a/proto/orchestrator/v1/plugin_registry.proto +++ b/proto/orchestrator/v1/plugin_registry.proto @@ -153,7 +153,14 @@ message CreatePluginResponse { Plugin plugin = 1; } -message GetPluginRequest { string scope_slug = 1; string name = 2; } +message GetPluginRequest { + string scope_slug = 1; + string name = 2; + // active_account_id is required when scope_slug = "private" so the server + // can resolve (account_id, name) instead of (scope_id, name). Ignored + // otherwise. + string active_account_id = 3; +} message GetPluginResponse { Plugin plugin = 1; repeated Version versions = 2;