Compare commits
2 Commits
264116f44e
...
c4d00a11d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d00a11d9 | ||
|
|
7615bd92ca |
20
Makefile
20
Makefile
@ -3,6 +3,8 @@ SDK_VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||
|
||||
SDK_DOWNSTREAM_DIRS := \
|
||||
$(HOME)/src/blockninja/backend \
|
||||
$(HOME)/src/orchestrator/backend \
|
||||
$(wildcard $(HOME)/src/blockninja-themes/*) \
|
||||
$(HOME)/src/assumechaos \
|
||||
$(HOME)/src/bidbuddy \
|
||||
$(HOME)/src/bidmasters \
|
||||
@ -11,6 +13,10 @@ SDK_DOWNSTREAM_DIRS := \
|
||||
$(HOME)/src/perthplaygrounds \
|
||||
$(HOME)/src/symposium
|
||||
|
||||
.PHONY: install-ninja
|
||||
install-ninja:
|
||||
go install ./cmd/ninja
|
||||
|
||||
.PHONY: update-sdk
|
||||
update-sdk:
|
||||
@set -e; \
|
||||
@ -32,6 +38,20 @@ update-sdk:
|
||||
); \
|
||||
done
|
||||
|
||||
# Tag + push a new SDK release, then bump the pin in every downstream repo.
|
||||
# Usage: make release VERSION=v0.11.0
|
||||
.PHONY: release
|
||||
release:
|
||||
@if [ -z "$(VERSION)" ]; then echo "usage: make release VERSION=vX.Y.Z" >&2; exit 1; fi
|
||||
@case "$(VERSION)" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "VERSION must look like vX.Y.Z (got $(VERSION))" >&2; exit 1 ;; esac
|
||||
@if ! git diff-index --quiet HEAD --; then echo "working tree dirty; commit before releasing" >&2; exit 1; fi
|
||||
git push origin HEAD
|
||||
git tag -a "$(VERSION)" -m "release $(VERSION)"
|
||||
git push origin "$(VERSION)"
|
||||
$(MAKE) update-sdk SDK_VERSION=$(VERSION)
|
||||
@echo "==> $(VERSION) tagged + pushed; downstream go.mod files bumped."
|
||||
@echo " Review each downstream and commit the pin bump."
|
||||
|
||||
.PHONY: check-sdk-pins
|
||||
check-sdk-pins:
|
||||
@set -e; \
|
||||
|
||||
150
cmd/ninja/cmd/account.go
Normal file
150
cmd/ninja/cmd/account.go
Normal 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
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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 <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 {
|
||||
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 <slug>`")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// --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
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@ -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...)
|
||||
|
||||
@ -15,5 +15,6 @@ func NewRoot() *cobra.Command {
|
||||
root.AddCommand(newWhoamiCmd())
|
||||
root.AddCommand(newPluginCmd())
|
||||
root.AddCommand(newScopeCmd())
|
||||
root.AddCommand(newAccountCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
45
cmd/ninja/internal/creds/creds_test.go
Normal file
45
cmd/ninja/internal/creds/creds_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 <treeish>` → 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.
|
||||
|
||||
@ -1011,6 +1011,10 @@ 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"`
|
||||
// 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
|
||||
}
|
||||
@ -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" +
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user