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 {
|
||||
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
|
||||
deadline := time.Now().Add(time.Duration(start.Msg.ExpiresInSeconds) * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
@ -19,7 +23,13 @@ import (
|
||||
|
||||
func newPluginCmd() *cobra.Command {
|
||||
c := &cobra.Command{Use: "plugin", Short: "Manage BlockNinja plugins"}
|
||||
c.AddCommand(newPluginInitCmd(), newPluginPublishCmd(), newPluginStatusCmd())
|
||||
c.AddCommand(
|
||||
newPluginInitCmd(),
|
||||
newPluginPublishCmd(),
|
||||
newPluginStatusCmd(),
|
||||
newPluginBumpCmd(),
|
||||
newPluginVersionCmd(),
|
||||
)
|
||||
return c
|
||||
}
|
||||
|
||||
@ -39,19 +49,39 @@ func newPluginInitCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
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()
|
||||
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{
|
||||
ScopeSlug: scope, Name: name, Description: "",
|
||||
ScopeSlug: scopeAPISlug(scope), Name: name, Description: "",
|
||||
}))
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
if _, err := os.Stat(".git"); err == nil {
|
||||
@ -71,11 +101,106 @@ func newPluginInitCmd() *cobra.Command {
|
||||
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")
|
||||
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 {
|
||||
var channel string
|
||||
var allowDirty bool
|
||||
@ -177,7 +302,7 @@ func newPluginStatusCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
c := exec.Command(name, args...)
|
||||
c.Stderr = os.Stderr
|
||||
|
||||
@ -14,5 +14,6 @@ func NewRoot() *cobra.Command {
|
||||
root.AddCommand(newLogoutCmd())
|
||||
root.AddCommand(newWhoamiCmd())
|
||||
root.AddCommand(newPluginCmd())
|
||||
root.AddCommand(newScopeCmd())
|
||||
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 {
|
||||
Token string `json:"token"`
|
||||
User string `json:"user,omitempty"`
|
||||
DefaultScope string `json:"default_scope,omitempty"`
|
||||
}
|
||||
|
||||
func filePath() (string, error) {
|
||||
|
||||
@ -3,6 +3,8 @@ package plugin
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
@ -44,3 +46,44 @@ func CompareVersions(v1, v2 string) int {
|
||||
}
|
||||
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