diff --git a/cmd/ninja/cmd/login.go b/cmd/ninja/cmd/login.go index 0733ad1..c10166e 100644 --- a/cmd/ninja/cmd/login.go +++ b/cmd/ninja/cmd/login.go @@ -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) { diff --git a/cmd/ninja/cmd/plugin.go b/cmd/ninja/cmd/plugin.go index 5213a9b..338f928 100644 --- a/cmd/ninja/cmd/plugin.go +++ b/cmd/ninja/cmd/plugin.go @@ -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 diff --git a/cmd/ninja/cmd/root.go b/cmd/ninja/cmd/root.go index 379924f..e2a547d 100644 --- a/cmd/ninja/cmd/root.go +++ b/cmd/ninja/cmd/root.go @@ -14,5 +14,6 @@ func NewRoot() *cobra.Command { root.AddCommand(newLogoutCmd()) root.AddCommand(newWhoamiCmd()) root.AddCommand(newPluginCmd()) + root.AddCommand(newScopeCmd()) return root } diff --git a/cmd/ninja/cmd/scope.go b/cmd/ninja/cmd/scope.go new file mode 100644 index 0000000..fa4d1ed --- /dev/null +++ b/cmd/ninja/cmd/scope.go @@ -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 + }, + } +} diff --git a/cmd/ninja/internal/creds/creds.go b/cmd/ninja/internal/creds/creds.go index 4b0757b..e349af1 100644 --- a/cmd/ninja/internal/creds/creds.go +++ b/cmd/ninja/internal/creds/creds.go @@ -13,8 +13,9 @@ type Credentials struct { } type HostCreds struct { - Token string `json:"token"` - User string `json:"user,omitempty"` + Token string `json:"token"` + User string `json:"user,omitempty"` + DefaultScope string `json:"default_scope,omitempty"` } func filePath() (string, error) { diff --git a/plugin/version.go b/plugin/version.go index 0b570cf..e357da7 100644 --- a/plugin/version.go +++ b/plugin/version.go @@ -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 +} diff --git a/plugin/version_test.go b/plugin/version_test.go new file mode 100644 index 0000000..b2790d3 --- /dev/null +++ b/plugin/version_test.go @@ -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) + } + }) + } +}