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.
This commit is contained in:
parent
1d9ca44f55
commit
2a76b30c51
@ -33,7 +33,7 @@ func newLoginCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("start device: %w", err)
|
return fmt.Errorf("start device: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Visit %s and enter code: %s\n", start.Msg.VerificationUri, start.Msg.UserCode)
|
fmt.Printf("Visit %s?user_code=%s to authorize.\n", start.Msg.VerificationUri, start.Msg.UserCode)
|
||||||
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
|
interval := time.Duration(start.Msg.IntervalSeconds) * time.Second
|
||||||
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
|
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
@ -19,7 +23,13 @@ import (
|
|||||||
|
|
||||||
func newPluginCmd() *cobra.Command {
|
func newPluginCmd() *cobra.Command {
|
||||||
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
|
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
|
||||||
c.AddCommand(newPluginInitCmd(), newPluginPublishCmd(), newPluginStatusCmd())
|
c.AddCommand(
|
||||||
|
newPluginInitCmd(),
|
||||||
|
newPluginPublishCmd(),
|
||||||
|
newPluginStatusCmd(),
|
||||||
|
newPluginBumpCmd(),
|
||||||
|
newPluginVersionCmd(),
|
||||||
|
)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,19 +49,39 @@ func newPluginInitCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cli := orchclient.New(resolvedHost, hc.Token)
|
cli := orchclient.New(resolvedHost, hc.Token)
|
||||||
|
|
||||||
scope = strings.TrimPrefix(scope, "@")
|
|
||||||
if scope == "" || name == "" {
|
|
||||||
return fmt.Errorf("usage: ninja plugin init --scope ACME --name foo")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
|
||||||
|
if scope != "" {
|
||||||
|
scope, err = parseScope(scope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scope, err = promptScope(ctx, cli, cr, resolvedHost, hc, scanner)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
fmt.Print("Plugin name: ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return fmt.Errorf("cancelled")
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(scanner.Text())
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("plugin name is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
|
resp, err := cli.Reg.CreatePlugin(ctx, connect.NewRequest(&v1.CreatePluginRequest{
|
||||||
ScopeSlug: scope, Name: name, Description: "",
|
ScopeSlug: scopeAPISlug(scope), Name: name, Description: "",
|
||||||
}))
|
}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("Created @%s/%s\n", scope, name)
|
fmt.Printf("\nCreated %s/%s\n", scope, name)
|
||||||
fmt.Printf(" Git remote: %s\n", resp.Msg.GitRemoteUrl)
|
fmt.Printf(" Git remote: %s\n", resp.Msg.GitRemoteUrl)
|
||||||
|
|
||||||
if _, err := os.Stat(".git"); err == nil {
|
if _, err := os.Stat(".git"); err == nil {
|
||||||
@ -71,11 +101,106 @@ func newPluginInitCmd() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&scope, "scope", "", "Scope slug (with or without @)")
|
cmd.Flags().StringVar(&scope, "scope", "", "Scope (e.g. @acme)")
|
||||||
cmd.Flags().StringVar(&name, "name", "", "Plugin name")
|
cmd.Flags().StringVar(&name, "name", "", "Plugin name")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptScope(ctx context.Context, cli *orchclient.Client, cr *creds.Credentials, host string, hc creds.HostCreds, scanner *bufio.Scanner) (string, error) {
|
||||||
|
if hc.DefaultScope != "" {
|
||||||
|
fmt.Printf("Using default scope: %s (override with --scope)\n", hc.DefaultScope)
|
||||||
|
return hc.DefaultScope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes, err := cli.Scope.ListMyScopes(ctx, connect.NewRequest(&v1.ListMyScopesRequest{}))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("listing scopes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("A scope is your organisation or personal namespace for plugins (like @mycompany).")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
var scope string
|
||||||
|
if len(scopes.Msg.Scopes) == 0 {
|
||||||
|
fmt.Println("You don't have any scopes yet. Let's create one.")
|
||||||
|
fmt.Println()
|
||||||
|
s, err := createScopeInline(ctx, cli, scanner)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scope = s
|
||||||
|
} else {
|
||||||
|
fmt.Println("Your scopes:")
|
||||||
|
for i, s := range scopes.Msg.Scopes {
|
||||||
|
fmt.Printf(" %d. @%s — %s\n", i+1, s.Slug, s.DisplayName)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Print("Select a scope [1]: ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return "", fmt.Errorf("cancelled")
|
||||||
|
}
|
||||||
|
input := strings.TrimSpace(scanner.Text())
|
||||||
|
|
||||||
|
if input == "" {
|
||||||
|
scope = "@" + scopes.Msg.Scopes[0].Slug
|
||||||
|
} else 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nSave %s as your default scope? [Y/n]: ", scope)
|
||||||
|
if scanner.Scan() {
|
||||||
|
ans := strings.ToLower(strings.TrimSpace(scanner.Text()))
|
||||||
|
if ans == "" || ans == "y" || ans == "yes" {
|
||||||
|
hc.DefaultScope = scope
|
||||||
|
cr.Hosts[host] = 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 scope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createScopeInline(ctx context.Context, cli *orchclient.Client, 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")
|
||||||
|
}
|
||||||
|
slug, err := parseScope(scanner.Text())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Display name [%s]: ", scopeAPISlug(slug))
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return "", fmt.Errorf("cancelled")
|
||||||
|
}
|
||||||
|
displayName := strings.TrimSpace(scanner.Text())
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = scopeAPISlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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\n", slug)
|
||||||
|
return slug, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newPluginPublishCmd() *cobra.Command {
|
func newPluginPublishCmd() *cobra.Command {
|
||||||
var channel string
|
var channel string
|
||||||
var allowDirty bool
|
var allowDirty bool
|
||||||
@ -177,7 +302,7 @@ func newPluginStatusCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(scopes.Msg.Scopes) == 0 {
|
if len(scopes.Msg.Scopes) == 0 {
|
||||||
fmt.Println("No scopes yet. Create one in the orchestrator UI or via a CreateScope call.")
|
fmt.Println("No scopes yet. Create one with: ninja scope create")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, s := range scopes.Msg.Scopes {
|
for _, s := range scopes.Msg.Scopes {
|
||||||
@ -196,6 +321,159 @@ func newPluginStatusCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newPluginBumpCmd() *cobra.Command {
|
||||||
|
var setVersion string
|
||||||
|
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.",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(c *cobra.Command, args []string) error {
|
||||||
|
modBytes, err := os.ReadFile("plugin.mod")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read plugin.mod: %w", err)
|
||||||
|
}
|
||||||
|
mod, err := core.ParseModFull(modBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mod.Plugin.Version == "" {
|
||||||
|
return fmt.Errorf("plugin.mod has no version")
|
||||||
|
}
|
||||||
|
old := mod.Plugin.Version
|
||||||
|
|
||||||
|
var next string
|
||||||
|
if setVersion != "" {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Errorf("cannot combine --set with bump argument")
|
||||||
|
}
|
||||||
|
if _, _, _, err := core.ParseBaseSemver(setVersion); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next = setVersion
|
||||||
|
} else {
|
||||||
|
level := "patch"
|
||||||
|
if len(args) > 0 {
|
||||||
|
level = args[0]
|
||||||
|
}
|
||||||
|
next, err = core.BumpVersion(old, level)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod.Plugin.Version = next
|
||||||
|
if err := writeMod("plugin.mod", mod); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s -> %s\n", old, next)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&setVersion, "set", "", "Set explicit version (e.g. 0.5.0)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPluginVersionCmd() *cobra.Command {
|
||||||
|
var short bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Show local plugin version and registry channels",
|
||||||
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
|
modBytes, err := os.ReadFile("plugin.mod")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read plugin.mod: %w", err)
|
||||||
|
}
|
||||||
|
mod, err := core.ParseModFull(modBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if short {
|
||||||
|
fmt.Println(mod.Plugin.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
local := mod.Plugin.Version
|
||||||
|
if local == "" {
|
||||||
|
local = "(unset)"
|
||||||
|
}
|
||||||
|
fmt.Printf("local: %s\n", local)
|
||||||
|
|
||||||
|
host, _ := c.Flags().GetString("host")
|
||||||
|
cr, err := creds.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("(registry: %v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resolvedHost, hc, err := cr.Resolve(host)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("(registry: %v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mod.Plugin.Scope == "" || mod.Plugin.Name == "" {
|
||||||
|
fmt.Println("(registry: plugin.mod missing scope or name)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := orchclient.New(resolvedHost, hc.Token)
|
||||||
|
pr, err := cli.Reg.GetPlugin(context.Background(), connect.NewRequest(&v1.GetPluginRequest{
|
||||||
|
ScopeSlug: mod.Plugin.Scope, Name: mod.Plugin.Name,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("(registry: %v)\n", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
verByString := make(map[string]*v1.Version, len(pr.Msg.Versions))
|
||||||
|
for _, v := range pr.Msg.Versions {
|
||||||
|
verByString[v.Version] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(pr.Msg.Channels))
|
||||||
|
for ch := range pr.Msg.Channels {
|
||||||
|
names = append(names, ch)
|
||||||
|
}
|
||||||
|
sort.Slice(names, func(i, j int) bool {
|
||||||
|
if names[i] == "latest" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if names[j] == "latest" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return names[i] < names[j]
|
||||||
|
})
|
||||||
|
for _, ch := range names {
|
||||||
|
ver := pr.Msg.Channels[ch]
|
||||||
|
date := ""
|
||||||
|
if v, ok := verByString[ver]; ok && v.PublishedAt != nil {
|
||||||
|
date = " (published " + v.PublishedAt.AsTime().Format("2006-01-02") + ")"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-8s %s%s\n", ch+":", ver, date)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&short, "short", false, "Print only the local version")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeSlugRe = regexp.MustCompile(`^[a-z][a-z0-9-]{2,}$`)
|
||||||
|
|
||||||
|
func parseScope(input string) (string, error) {
|
||||||
|
raw := strings.TrimPrefix(strings.TrimSpace(input), "@")
|
||||||
|
if raw == "" {
|
||||||
|
return "", fmt.Errorf("scope is required")
|
||||||
|
}
|
||||||
|
if !scopeSlugRe.MatchString(raw) {
|
||||||
|
return "", fmt.Errorf("invalid scope %q: must be at least 3 characters, lowercase letters, numbers, and dashes", raw)
|
||||||
|
}
|
||||||
|
return "@" + raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeAPISlug(scope string) string {
|
||||||
|
return strings.TrimPrefix(scope, "@")
|
||||||
|
}
|
||||||
|
|
||||||
func runCmd(name string, args ...string) error {
|
func runCmd(name string, args ...string) error {
|
||||||
c := exec.Command(name, args...)
|
c := exec.Command(name, args...)
|
||||||
c.Stderr = os.Stderr
|
c.Stderr = os.Stderr
|
||||||
|
|||||||
@ -14,5 +14,6 @@ func NewRoot() *cobra.Command {
|
|||||||
root.AddCommand(newLogoutCmd())
|
root.AddCommand(newLogoutCmd())
|
||||||
root.AddCommand(newWhoamiCmd())
|
root.AddCommand(newWhoamiCmd())
|
||||||
root.AddCommand(newPluginCmd())
|
root.AddCommand(newPluginCmd())
|
||||||
|
root.AddCommand(newScopeCmd())
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|||||||
230
cmd/ninja/cmd/scope.go
Normal file
230
cmd/ninja/cmd/scope.go
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ type Credentials struct {
|
|||||||
type HostCreds struct {
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func filePath() (string, error) {
|
func filePath() (string, error) {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
@ -44,3 +46,44 @@ func CompareVersions(v1, v2 string) int {
|
|||||||
}
|
}
|
||||||
return semver.Compare(v1, v2)
|
return semver.Compare(v1, v2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseBaseSemver parses a plain MAJOR.MINOR.PATCH version into integers.
|
||||||
|
// Rejects pre-release suffixes and build metadata.
|
||||||
|
func ParseBaseSemver(s string) (major, minor, patch int, err error) {
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid version %q: expected MAJOR.MINOR.PATCH", s)
|
||||||
|
}
|
||||||
|
out := [3]int{}
|
||||||
|
for i, p := range parts {
|
||||||
|
n, perr := strconv.Atoi(p)
|
||||||
|
if perr != nil || n < 0 {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid version %q: each part must be a non-negative integer", s)
|
||||||
|
}
|
||||||
|
out[i] = n
|
||||||
|
}
|
||||||
|
return out[0], out[1], out[2], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BumpVersion returns current bumped at the given level ("major", "minor", or "patch").
|
||||||
|
// Bumping major resets minor and patch to 0; bumping minor resets patch to 0.
|
||||||
|
func BumpVersion(current, level string) (string, error) {
|
||||||
|
major, minor, patch, err := ParseBaseSemver(current)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch level {
|
||||||
|
case "major":
|
||||||
|
major++
|
||||||
|
minor = 0
|
||||||
|
patch = 0
|
||||||
|
case "minor":
|
||||||
|
minor++
|
||||||
|
patch = 0
|
||||||
|
case "patch":
|
||||||
|
patch++
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown bump level %q (want major|minor|patch)", level)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil
|
||||||
|
}
|
||||||
|
|||||||
77
plugin/version_test.go
Normal file
77
plugin/version_test.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseBaseSemver(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
major, minor, patch int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"0.1.0", 0, 1, 0, false},
|
||||||
|
{"1.0.0", 1, 0, 0, false},
|
||||||
|
{"12.34.567", 12, 34, 567, false},
|
||||||
|
{"0.0.0", 0, 0, 0, false},
|
||||||
|
{"v0.1.0", 0, 0, 0, true},
|
||||||
|
{"0.1", 0, 0, 0, true},
|
||||||
|
{"0.1.0.0", 0, 0, 0, true},
|
||||||
|
{"0.1.0-beta", 0, 0, 0, true},
|
||||||
|
{"0.1.0+build", 0, 0, 0, true},
|
||||||
|
{"-1.0.0", 0, 0, 0, true},
|
||||||
|
{"a.b.c", 0, 0, 0, true},
|
||||||
|
{"", 0, 0, 0, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.in, func(t *testing.T) {
|
||||||
|
maj, min, pat, err := ParseBaseSemver(c.in)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("ParseBaseSemver(%q): expected error, got %d.%d.%d", c.in, maj, min, pat)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBaseSemver(%q): unexpected error: %v", c.in, err)
|
||||||
|
}
|
||||||
|
if maj != c.major || min != c.minor || pat != c.patch {
|
||||||
|
t.Errorf("ParseBaseSemver(%q) = %d.%d.%d, want %d.%d.%d", c.in, maj, min, pat, c.major, c.minor, c.patch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBumpVersion(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
current, level, want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"0.1.0", "patch", "0.1.1", false},
|
||||||
|
{"0.1.0", "minor", "0.2.0", false},
|
||||||
|
{"0.1.0", "major", "1.0.0", false},
|
||||||
|
{"1.2.3", "patch", "1.2.4", false},
|
||||||
|
{"1.2.3", "minor", "1.3.0", false},
|
||||||
|
{"1.2.3", "major", "2.0.0", false},
|
||||||
|
{"0.0.0", "patch", "0.0.1", false},
|
||||||
|
{"0.1.0", "build", "", true},
|
||||||
|
{"0.1.0", "", "", true},
|
||||||
|
{"v0.1.0", "patch", "", true},
|
||||||
|
{"", "patch", "", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.current+"/"+c.level, func(t *testing.T) {
|
||||||
|
got, err := BumpVersion(c.current, c.level)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("BumpVersion(%q, %q): expected error, got %q", c.current, c.level, got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BumpVersion(%q, %q): unexpected error: %v", c.current, c.level, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("BumpVersion(%q, %q) = %q, want %q", c.current, c.level, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user