core/cmd/ninja/cmd/scope.go
Alex Dunmow 2a76b30c51 feat(cli): scope subcommand, interactive scope prompt, bump+version helpers
Pre-existing CLI improvements ahead of the tarball-publish refactor:
- New top-level `ninja scope` command (create, list, set-default).
- `init` accepts no --scope: prompts from ListMyScopes or uses creds default.
- Plugin name prompted if not provided.
- `plugin bump <major|minor|patch>` writes the bumped version into plugin.mod.
- `plugin version` prints the current plugin.mod version.
- `login` prints a URL with ?user_code= so the link is one click.
- creds: HostCreds gains optional default_scope.
- plugin/version: ParseBaseSemver + BumpVersion helpers, with tests.
2026-06-03 01:18:11 +08:00

231 lines
5.6 KiB
Go

package cmd
import (
"bufio"
"context"
"fmt"
"os"
"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 newScopeCmd() *cobra.Command {
c := &cobra.Command{Use: "scope", Short: "Manage plugin scopes"}
c.AddCommand(newScopeCreateCmd(), newScopeListCmd(), newScopeDefaultCmd())
return c
}
func newScopeCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create [scope]",
Short: "Create a new scope (organisation namespace for plugins)",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scanner := bufio.NewScanner(os.Stdin)
var slug string
if len(args) > 0 {
slug, err = parseScope(args[0])
if err != nil {
return err
}
} else {
slug, err = promptScopeSlug(scanner)
if err != nil {
return err
}
}
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
displayName := scopeAPISlug(slug)
if scanner.Scan() {
if v := strings.TrimSpace(scanner.Text()); v != "" {
displayName = v
}
}
_, err = cli.Scope.CreateScope(ctx, connect.NewRequest(&v1.CreateScopeRequest{
Slug: scopeAPISlug(slug),
DisplayName: displayName,
}))
if err != nil {
return err
}
fmt.Printf("Created scope %s\n", slug)
fmt.Printf("Set %s as your default scope? [Y/n]: ", slug)
if scanner.Scan() {
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
if ans == "" || ans == "y" || ans == "yes" {
hc.DefaultScope = slug
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not save default scope: %v\n", err)
} else {
fmt.Println("Default scope saved.")
}
}
}
return nil
},
}
return cmd
}
func promptScopeSlug(scanner *bufio.Scanner) (string, error) {
fmt.Println("A scope is an organisation namespace for your plugins (e.g. @acme).")
fmt.Println("It appears in plugin names like @acme/my-plugin.")
fmt.Println()
fmt.Print("Scope slug (lowercase letters, numbers, dashes): ")
if !scanner.Scan() {
return "", fmt.Errorf("cancelled")
}
return parseScope(scanner.Text())
}
func newScopeDefaultCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "default",
Short: "Show or change the default scope",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
_, hc, err := cr.Resolve(host)
if err != nil {
return err
}
if hc.DefaultScope == "" {
fmt.Println("No default scope set. Run: ninja scope default set")
} else {
fmt.Println(hc.DefaultScope)
}
return nil
},
}
cmd.AddCommand(newScopeDefaultSetCmd())
return cmd
}
func newScopeDefaultSetCmd() *cobra.Command {
return &cobra.Command{
Use: "set",
Short: "Pick a default scope from your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
fmt.Println("Your scopes:")
for i, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (current)"
}
fmt.Printf(" %d. @%s — %s%s\n", i+1, s.Slug, s.DisplayName, marker)
}
fmt.Println()
scanner := bufio.NewScanner(os.Stdin)
fmt.Print("Select a scope: ")
if !scanner.Scan() {
return fmt.Errorf("cancelled")
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
return fmt.Errorf("cancelled")
}
var scope string
if n, err := strconv.Atoi(input); err == nil && n >= 1 && n <= len(scopes.Msg.Scopes) {
scope = "@" + scopes.Msg.Scopes[n-1].Slug
} else {
return fmt.Errorf("invalid selection: %s", input)
}
hc.DefaultScope = scope
cr.Hosts[resolvedHost] = hc
if err := cr.Save(); err != nil {
return err
}
fmt.Printf("Default scope set to %s\n", scope)
return nil
},
}
}
func newScopeListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List your scopes",
RunE: func(c *cobra.Command, _ []string) error {
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return err
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return err
}
cli := orchclient.New(resolvedHost, hc.Token)
ctx := context.Background()
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
if err != nil {
return err
}
if len(scopes.Msg.Scopes) == 0 {
fmt.Println("No scopes yet. Create one with: ninja scope create")
return nil
}
for _, s := range scopes.Msg.Scopes {
marker := ""
if "@"+s.Slug == hc.DefaultScope {
marker = " (default)"
}
fmt.Printf("@%s — %s%s\n", s.Slug, s.DisplayName, marker)
}
return nil
},
}
}