feat(cli): multi-account login, private-plugin SDK, publish dirty handling

- ninja login forces account selection (interactive when >1); creds now
  carry ActiveAccountID/Slug. New `ninja account` group.
- ninja plugin list / delete / delete-version split public vs active-account
  @private sections; `publish --private` is sticky in plugin.mod.
- GetPluginRequest gains active_account_id so @private resolution works
  alongside the public (scope, name) path.
- publish auto-commits a dirty plugin.mod (path-scoped, leaves other staged
  paths alone) so the bump→publish loop never trips the dirty check.
  --allow-dirty is replaced with --strict (default now ships dirty trees
  via stash-create).
- bump auto-commits its plugin.mod write with `bump to X.Y.Z`; --no-commit
  opts out.
- Design doc updated to match the new defaults.
This commit is contained in:
Alex Dunmow 2026-06-04 08:49:23 +08:00
parent 264116f44e
commit 7615bd92ca
10 changed files with 746 additions and 49 deletions

150
cmd/ninja/cmd/account.go Normal file
View File

@ -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 <slug>",
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 <slug>`)")
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
}

View File

@ -1,8 +1,10 @@
package cmd package cmd
import ( import (
"bufio"
"context" "context"
"fmt" "fmt"
"os"
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
@ -54,7 +56,12 @@ func newLoginCmd() *cobra.Command {
if cr.Hosts == nil { if cr.Hosts == nil {
cr.Hosts = map[string]creds.HostCreds{} 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 { if err := cr.Save(); err != nil {
return err 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 { func newLogoutCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "logout", Use: "logout",

View File

@ -29,14 +29,200 @@ func newPluginCmd() *cobra.Command {
newPluginInitCmd(), newPluginInitCmd(),
newPluginPublishCmd(), newPluginPublishCmd(),
newPluginStatusCmd(), newPluginStatusCmd(),
newPluginListCmd(),
newPluginDeleteCmd(),
newPluginDeleteVersionCmd(),
newPluginBumpCmd(), newPluginBumpCmd(),
newPluginVersionCmd(), newPluginVersionCmd(),
) )
return c 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 <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
}
// 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/<name>, 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 <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 { func newPluginInitCmd() *cobra.Command {
var scope, name string var scope, name string
var private bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "init", Use: "init",
Short: "Create a plugin in the registry and add a git remote", 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 { 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 != "": case scope != "":
scope, err = parseScope(scope) scope, err = parseScope(scope)
if err != nil { if err != nil {
@ -74,6 +272,7 @@ func newPluginInitCmd() *cobra.Command {
case existing.Plugin.Scope != "": case existing.Plugin.Scope != "":
scope = "@" + strings.TrimPrefix(existing.Plugin.Scope, "@") scope = "@" + strings.TrimPrefix(existing.Plugin.Scope, "@")
default: default:
// Scope precedence: --scope flag > plugin.mod scope > interactive prompt.
scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner) scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner)
if err != nil { if err != nil {
return err 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), ScopeSlug: scopeAPISlug(scope),
Name: name, Name: name,
DisplayName: displayName, DisplayName: displayName,
Description: description, Description: description,
Kind: kind, Kind: kind,
Categories: cats, 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 return err
} }
fmt.Printf("\nCreated %s/%s\n", scope, name) 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 return err
} }
fmt.Println("plugin.mod updated") fmt.Println("plugin.mod updated")
if _, err := os.Stat(".git"); err == nil { 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) fmt.Fprintf(os.Stderr, "warning: could not commit plugin.mod: %v\n", err)
} }
} else { } else {
@ -168,6 +379,7 @@ func newPluginInitCmd() *cobra.Command {
} }
cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)") cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)")
cmd.Flags().StringVar(&name, "name", "", "Plugin name") 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 return cmd
} }
@ -376,7 +588,8 @@ func pickedSet(slugs []string) map[string]struct{} {
func newPluginPublishCmd() *cobra.Command { func newPluginPublishCmd() *cobra.Command {
var channel string var channel string
var allowDirty bool var strict bool
var privateFlag bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "publish", Use: "publish",
Short: "Build a source archive and publish a new version to the registry", Short: "Build a source archive and publish a new version to the registry",
@ -400,15 +613,52 @@ func newPluginPublishCmd() *cobra.Command {
if err != nil { if err != nil {
return err 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 == "" { if mod.Plugin.Scope == "" || mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return fmt.Errorf("plugin.mod must have scope, name, and version") return fmt.Errorf("plugin.mod must have scope, name, and version")
} }
}
if err := checkRepoHasHEAD("."); err != nil { if err := checkRepoHasHEAD("."); err != nil {
return err 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 return err
} }
@ -418,9 +668,15 @@ func newPluginPublishCmd() *cobra.Command {
} }
ctx := context.Background() ctx := context.Background()
pr, err := cli.Reg.GetPlugin(ctx, connect.NewRequest(&v1.GetPluginRequest{ getReq := &v1.GetPluginRequest{
ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name, 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 { if err != nil {
return fmt.Errorf("get plugin: %w", err) 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().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 return cmd
} }
@ -493,10 +750,11 @@ func newPluginStatusCmd() *cobra.Command {
func newPluginBumpCmd() *cobra.Command { func newPluginBumpCmd() *cobra.Command {
var setVersion string var setVersion string
var noCommit bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "bump [major|minor|patch]", Use: "bump [major|minor|patch]",
Short: "Bump the version in plugin.mod (default: 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), Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
modBytes, err := os.ReadFile("plugin.mod") modBytes, err := os.ReadFile("plugin.mod")
@ -537,10 +795,19 @@ func newPluginBumpCmd() *cobra.Command {
return err return err
} }
fmt.Printf("%s -> %s\n", old, next) 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 return nil
}, },
} }
cmd.Flags().StringVar(&setVersion, "set", "", "Set explicit version (e.g. 0.5.0)") 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 return cmd
} }
@ -685,7 +952,7 @@ func submodulePaths(repoDir string) []string {
return paths 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" const file = "plugin.mod"
existing, _ := os.ReadFile(file) existing, _ := os.ReadFile(file)
mod, _ := core.ParseModFull(existing) mod, _ := core.ParseModFull(existing)
@ -701,6 +968,7 @@ func upsertPluginMod(scope, name, displayName, description, kind string, categor
mod.Plugin.Description = description mod.Plugin.Description = description
mod.Plugin.Kind = kind mod.Plugin.Kind = kind
mod.Plugin.Categories = categories mod.Plugin.Categories = categories
mod.Plugin.Private = private
return writeMod(file, mod) 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, ", "))) b.WriteString(fmt.Sprintf("categories = [%s]\n", strings.Join(quoted, ", ")))
} }
if m.Plugin.Private {
b.WriteString("private = true\n")
}
if m.Compatibility != nil { if m.Compatibility != nil {
b.WriteString("\n[compatibility]\n") b.WriteString("\n[compatibility]\n")
b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore)) 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 // 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 // only captures tracked content, so untracked files silently vanish from the
// archive. // archive.
func untrackedFilesWarning(repoDir string, w io.Writer) { func untrackedFilesWarning(repoDir string, w io.Writer) {
@ -768,7 +1039,7 @@ func untrackedFilesWarning(repoDir string, w io.Writer) {
if names == "" { if names == "" {
return 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") { for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(w, " "+n) fmt.Fprintln(w, " "+n)
} }
@ -779,12 +1050,12 @@ func untrackedFilesWarning(repoDir string, w io.Writer) {
// CLI expects: // CLI expects:
// //
// 1. gitignoredTrackedWarning is unconditional so the developer sees it even // 1. gitignoredTrackedWarning is unconditional so the developer sees it even
// when the publish is about to abort. // when the publish is about to abort under --strict.
// 2. If allowDirty is false the working tree must be clean — a dirty tree // 2. If allowDirty is false (i.e. --strict) the working tree must be clean —
// returns an error and the remaining warnings (which are only useful on // a dirty tree returns an error and the remaining warnings (which are
// the proceeding-publish path) are skipped. // only useful on the proceeding-publish path) are skipped.
// 3. If allowDirty is true the untracked-files warning fires so the user // 3. If allowDirty is true (the default) the untracked-files warning fires
// knows those files won't be in the archive. // so the user knows those files won't be in the archive.
// 4. submoduleWarning is unconditional on the proceeding path because // 4. submoduleWarning is unconditional on the proceeding path because
// `git archive` does not recurse into submodules. // `git archive` does not recurse into submodules.
func emitPublishWarnings(repoDir string, allowDirty bool, w io.Writer) error { 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 cmd.Dir = repoDir
out, _ := cmd.Output() out, _ := cmd.Output()
if len(strings.TrimSpace(string(out))) > 0 { 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 { } else {
// `git stash create` only captures tracked content, so untracked // `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)") 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 // autoCommitPluginMod stages and commits plugin.mod with the given message,
// what's already at HEAD. No-op when there's nothing to commit. // if plugin.mod differs from HEAD. No-op when there's nothing to commit.
func autoCommitPluginMod() error { //
// 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() out, err := exec.Command("git", "status", "--porcelain", "plugin.mod").Output()
if err != nil { if err != nil {
return fmt.Errorf("git status: %w", err) return fmt.Errorf("git status: %w", err)
@ -835,7 +1110,7 @@ func autoCommitPluginMod() error {
if err := runCmd("git", "add", "plugin.mod"); err != nil { if err := runCmd("git", "add", "plugin.mod"); err != nil {
return err 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 return err
} }
fmt.Println("Committed plugin.mod") fmt.Println("Committed plugin.mod")

View File

@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
core "git.dev.alexdunmow.com/block/core/plugin"
) )
func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) { func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) {
@ -54,7 +56,7 @@ func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) {
} }
t.Chdir(dir) t.Chdir(dir)
if err := autoCommitPluginMod(); err != nil { if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err) t.Fatalf("autoCommitPluginMod: %v", err)
} }
@ -77,7 +79,7 @@ func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) {
beforeSHA := gitHeadSHA(t, dir) beforeSHA := gitHeadSHA(t, dir)
t.Chdir(dir) t.Chdir(dir)
if err := autoCommitPluginMod(); err != nil { if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod: %v", err) t.Fatalf("autoCommitPluginMod: %v", err)
} }
@ -105,7 +107,7 @@ func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) {
} }
t.Chdir(dir) t.Chdir(dir)
if err := autoCommitPluginMod(); err != nil { if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err) 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) { func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
runGit(t, dir, "init", "-q") runGit(t, dir, "init", "-q")
@ -148,7 +221,7 @@ func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) {
t.Chdir(dir) t.Chdir(dir)
t.Setenv("PATH", "") t.Setenv("PATH", "")
err := autoCommitPluginMod() err := autoCommitPluginMod("Add plugin.mod")
if err == nil { if err == nil {
t.Fatal("expected error when git is missing from PATH, got 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") { if !strings.Contains(out, "scratch.txt") {
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out) 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) 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") { if !strings.Contains(err.Error(), "working tree dirty") {
t.Errorf("expected dirty-tree error, got: %v", err) 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()) 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) { func runGit(t *testing.T, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)

View File

@ -15,5 +15,6 @@ func NewRoot() *cobra.Command {
root.AddCommand(newWhoamiCmd()) root.AddCommand(newWhoamiCmd())
root.AddCommand(newPluginCmd()) root.AddCommand(newPluginCmd())
root.AddCommand(newScopeCmd()) root.AddCommand(newScopeCmd())
root.AddCommand(newAccountCmd())
return root return root
} }

View File

@ -16,6 +16,16 @@ type HostCreds struct {
Token string `json:"token"` Token string `json:"token"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
DefaultScope string `json:"default_scope,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) { func filePath() (string, error) {

View File

@ -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)
}
}

View File

@ -182,14 +182,28 @@ they can `publish` (`git archive` requires a repo).
`ninja plugin publish`: `ninja plugin publish`:
1. Read `plugin.mod`. 1. Read `plugin.mod`.
2. Working-tree-clean check unless `--allow-dirty`. 2. Auto-commit `plugin.mod` if it's dirty (path-scoped commit, so any other
3. Lookup `Plugin` via `GetPlugin(scope, name)` to verify it exists and to 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`. get its `id`.
4. `git archive --format=tar HEAD` → zstd compress → bytes in memory. 5. `git archive --format=tar <treeish>` → zstd compress → bytes in memory
5. Optional README/CHANGELOG bytes. (treeish is `HEAD` for a clean tree, the stash-create sha for a dirty
6. Call `PublishVersion(plugin_id, version, channel, archive, readme, one).
6. Optional README/CHANGELOG bytes.
7. Call `PublishVersion(plugin_id, version, channel, archive, readme,
changelog)`. 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 No tag is created. No git push. No `.git/config` writes anywhere in the
flow. flow.

View File

@ -1011,6 +1011,10 @@ type GetPluginRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ScopeSlug string `protobuf:"bytes,1,opt,name=scope_slug,json=scopeSlug,proto3" json:"scope_slug,omitempty"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1059,6 +1063,13 @@ func (x *GetPluginRequest) GetName() string {
return "" return ""
} }
func (x *GetPluginRequest) GetActiveAccountId() string {
if x != nil {
return x.ActiveAccountId
}
return ""
}
type GetPluginResponse struct { type GetPluginResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Plugin *Plugin `protobuf:"bytes,1,opt,name=plugin,proto3" json:"plugin,omitempty"` 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" + "visibility\x12*\n" +
"\x11active_account_id\x18\b \x01(\tR\x0factiveAccountId\"G\n" + "\x11active_account_id\x18\b \x01(\tR\x0factiveAccountId\"G\n" +
"\x14CreatePluginResponse\x12/\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" + "\x10GetPluginRequest\x12\x1d\n" +
"\n" + "\n" +
"scope_slug\x18\x01 \x01(\tR\tscopeSlug\x12\x12\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" + "\x11GetPluginResponse\x12/\n" +
"\x06plugin\x18\x01 \x01(\v2\x17.orchestrator.v1.PluginR\x06plugin\x124\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" + "\bversions\x18\x02 \x03(\v2\x18.orchestrator.v1.VersionR\bversions\x12L\n" +

View File

@ -153,7 +153,14 @@ message CreatePluginResponse {
Plugin plugin = 1; 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 { message GetPluginResponse {
Plugin plugin = 1; Plugin plugin = 1;
repeated Version versions = 2; repeated Version versions = 2;