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 := \
|
SDK_DOWNSTREAM_DIRS := \
|
||||||
$(HOME)/src/blockninja/backend \
|
$(HOME)/src/blockninja/backend \
|
||||||
|
$(HOME)/src/orchestrator/backend \
|
||||||
|
$(wildcard $(HOME)/src/blockninja-themes/*) \
|
||||||
$(HOME)/src/assumechaos \
|
$(HOME)/src/assumechaos \
|
||||||
$(HOME)/src/bidbuddy \
|
$(HOME)/src/bidbuddy \
|
||||||
$(HOME)/src/bidmasters \
|
$(HOME)/src/bidmasters \
|
||||||
@ -11,6 +13,10 @@ SDK_DOWNSTREAM_DIRS := \
|
|||||||
$(HOME)/src/perthplaygrounds \
|
$(HOME)/src/perthplaygrounds \
|
||||||
$(HOME)/src/symposium
|
$(HOME)/src/symposium
|
||||||
|
|
||||||
|
.PHONY: install-ninja
|
||||||
|
install-ninja:
|
||||||
|
go install ./cmd/ninja
|
||||||
|
|
||||||
.PHONY: update-sdk
|
.PHONY: update-sdk
|
||||||
update-sdk:
|
update-sdk:
|
||||||
@set -e; \
|
@set -e; \
|
||||||
@ -32,6 +38,20 @@ update-sdk:
|
|||||||
); \
|
); \
|
||||||
done
|
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
|
.PHONY: check-sdk-pins
|
||||||
check-sdk-pins:
|
check-sdk-pins:
|
||||||
@set -e; \
|
@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
|
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",
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
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 <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 {
|
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")
|
||||||
|
|||||||
@ -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...)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
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`:
|
`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.
|
||||||
|
|||||||
@ -1008,11 +1008,15 @@ func (x *CreatePluginResponse) GetPlugin() *Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetPluginRequest struct {
|
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"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// active_account_id is required when scope_slug = "private" so the server
|
||||||
sizeCache protoimpl.SizeCache
|
// 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() {
|
func (x *GetPluginRequest) Reset() {
|
||||||
@ -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" +
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user