Compare commits

..

No commits in common. "bb3ddfe1bd63624cfb8805f940ab3eb0987e2e6f" and "ba87684696a676899a7fb80513b98ff9dddea4b6" have entirely different histories.

5 changed files with 20 additions and 507 deletions

View File

@ -34,7 +34,6 @@ func newPluginCmd() *cobra.Command {
newPluginDeleteVersionCmd(),
newPluginBumpCmd(),
newPluginVersionCmd(),
newPluginTagsCmd(),
)
return c
}
@ -208,14 +207,14 @@ func parsePrivateCoord(s string) (string, error) {
return s, nil
}
rest := strings.TrimPrefix(s, "@")
before, after, ok := strings.Cut(rest, "/")
if !ok {
slash := strings.IndexByte(rest, '/')
if slash < 0 {
return "", fmt.Errorf("expected @%s/<name>, got %q", core.PrivateScopeSlug, s)
}
if before != core.PrivateScopeSlug {
return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, before)
if rest[:slash] != core.PrivateScopeSlug {
return "", fmt.Errorf("only @%s scope is supported for delete; got @%s", core.PrivateScopeSlug, rest[:slash])
}
return after, nil
return rest[slash+1:], nil
}
func printPrivateSection(ctx context.Context, cli *orchclient.Client, hc creds.HostCreds) {
@ -361,10 +360,6 @@ func newPluginInitCmd() *cobra.Command {
return err
}
}
tags, err := promptTagsWithDefault(ctx, cli, scanner, kind, existing.Plugin.Tags)
if err != nil {
return err
}
createReq := &v1.CreatePluginRequest{
ScopeSlug: scopeAPISlug(scope),
@ -390,7 +385,7 @@ func newPluginInitCmd() *cobra.Command {
if private {
modScope = ""
}
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, tags, private); err != nil {
if err := upsertPluginMod(modScope, name, displayName, description, kind, cats, private); err != nil {
return err
}
fmt.Println("plugin.mod updated")
@ -605,59 +600,6 @@ func promptCategoriesWithDefault(ctx context.Context, cli *orchclient.Client, sc
return out, nil
}
// fetchPopularTagsForPrompt returns a comma-joined "tag (count)" string of the
// top tags for the given kind, or "" if the orchestrator lookup fails. The
// current implementation always returns "" because core's copy of the
// orchestrator proto bindings does not yet expose ListTags; once the bindings
// are regenerated this body becomes a best-effort PluginRegistryService.ListTags
// call. Callers must already tolerate an empty return.
func fetchPopularTagsForPrompt(ctx context.Context, cli *orchclient.Client, kind string) string {
_ = ctx
_ = cli
_ = kind
return ""
}
// promptTagsWithDefault prompts the user for free-form tags. Best-effort fetches
// the top-20 most-used tags via ListTags(kind) to surface popular suggestions;
// if that call fails (offline, etc.) it falls back to a plain prompt with a
// one-line warning. Empty input keeps `current`. Validates with NormalizeTags;
// on error, prints the issue and reprompts (one re-try, then bails).
func promptTagsWithDefault(ctx context.Context, cli *orchclient.Client, scanner *bufio.Scanner, kind string, current []string) ([]string, error) {
popular := fetchPopularTagsForPrompt(ctx, cli, kind)
for attempt := range 2 {
if len(current) > 0 {
fmt.Printf("Tags (current: %s)\n", strings.Join(current, ", "))
} else {
fmt.Println("Tags (current: none)")
}
if popular != "" {
fmt.Printf("Popular: %s\n", popular)
} else if attempt == 0 {
fmt.Println("(could not fetch popular tags — offline or unauthenticated)")
}
fmt.Print("Enter comma-separated tags (blank to keep current): ")
if !scanner.Scan() {
return nil, fmt.Errorf("cancelled")
}
raw := strings.TrimSpace(scanner.Text())
if raw == "" {
return current, nil
}
parts := strings.Split(raw, ",")
out, err := core.NormalizeTags(parts)
if err == nil {
return out, nil
}
fmt.Printf(" %s\n", err.Error())
if attempt == 0 {
fmt.Println(" Try again:")
}
}
return nil, fmt.Errorf("tag input failed validation after 2 attempts")
}
// pickedSet returns a set of the given slugs for O(1) membership checks.
func pickedSet(slugs []string) map[string]struct{} {
s := make(map[string]struct{}, len(slugs))
@ -1023,7 +965,7 @@ func submodulePaths(repoDir string) []string {
return nil
}
var paths []string
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
// each line is "submodule.<name>.path <path>"
fields := strings.Fields(line)
if len(fields) >= 2 {
@ -1033,7 +975,7 @@ func submodulePaths(repoDir string) []string {
return paths
}
func upsertPluginMod(scope, name, displayName, description, kind string, categories, tags []string, private bool) error {
func upsertPluginMod(scope, name, displayName, description, kind string, categories []string, private bool) error {
const file = "plugin.mod"
existing, _ := os.ReadFile(file)
mod, _ := core.ParseModFull(existing)
@ -1049,7 +991,6 @@ func upsertPluginMod(scope, name, displayName, description, kind string, categor
mod.Plugin.Description = description
mod.Plugin.Kind = kind
mod.Plugin.Categories = categories
mod.Plugin.Tags = tags
mod.Plugin.Private = private
return writeMod(file, mod)
}
@ -1057,43 +998,36 @@ func upsertPluginMod(scope, name, displayName, description, kind string, categor
func writeMod(path string, m *core.ModFile) error {
var b strings.Builder
b.WriteString("[plugin]\n")
fmt.Fprintf(&b, "name = %q\n", m.Plugin.Name)
b.WriteString(fmt.Sprintf("name = %q\n", m.Plugin.Name))
if m.Plugin.DisplayName != "" {
fmt.Fprintf(&b, "display_name = %q\n", m.Plugin.DisplayName)
b.WriteString(fmt.Sprintf("display_name = %q\n", m.Plugin.DisplayName))
}
fmt.Fprintf(&b, "scope = %q\n", m.Plugin.Scope)
fmt.Fprintf(&b, "version = %q\n", m.Plugin.Version)
b.WriteString(fmt.Sprintf("scope = %q\n", m.Plugin.Scope))
b.WriteString(fmt.Sprintf("version = %q\n", m.Plugin.Version))
if m.Plugin.Description != "" {
fmt.Fprintf(&b, "description = %q\n", m.Plugin.Description)
b.WriteString(fmt.Sprintf("description = %q\n", m.Plugin.Description))
}
if m.Plugin.Kind != "" {
fmt.Fprintf(&b, "kind = %q\n", m.Plugin.Kind)
b.WriteString(fmt.Sprintf("kind = %q\n", m.Plugin.Kind))
}
if len(m.Plugin.Categories) > 0 {
quoted := make([]string, len(m.Plugin.Categories))
for i, c := range m.Plugin.Categories {
quoted[i] = fmt.Sprintf("%q", c)
}
fmt.Fprintf(&b, "categories = [%s]\n", strings.Join(quoted, ", "))
}
if len(m.Plugin.Tags) > 0 {
quoted := make([]string, len(m.Plugin.Tags))
for i, t := range m.Plugin.Tags {
quoted[i] = fmt.Sprintf("%q", t)
}
fmt.Fprintf(&b, "tags = [%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 {
b.WriteString("\n[compatibility]\n")
fmt.Fprintf(&b, "block_core = %q\n", m.Compatibility.BlockCore)
b.WriteString(fmt.Sprintf("block_core = %q\n", m.Compatibility.BlockCore))
}
for _, r := range m.Requires {
b.WriteString("\n[[requires]]\n")
fmt.Fprintf(&b, "name = %q\n", r.Name)
fmt.Fprintf(&b, "version = %q\n", r.Version)
b.WriteString(fmt.Sprintf("name = %q\n", r.Name))
b.WriteString(fmt.Sprintf("version = %q\n", r.Version))
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
@ -1110,7 +1044,7 @@ func gitignoredTrackedWarning(repoDir string, w io.Writer) {
return
}
fmt.Fprintln(w, "warning: these tracked files match .gitignore and will still be shipped:")
for n := range strings.SplitSeq(names, "\n") {
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(w, " "+n)
}
fmt.Fprintln(w, " (run `git rm --cached <file>` to drop)")
@ -1129,7 +1063,7 @@ func untrackedFilesWarning(repoDir string, w io.Writer) {
return
}
fmt.Fprintln(w, "warning: these untracked files will NOT be in the archive:")
for n := range strings.SplitSeq(names, "\n") {
for _, n := range strings.Split(names, "\n") {
fmt.Fprintln(w, " "+n)
}
fmt.Fprintln(w, " (run `git add <file>` if they should be shipped)")

View File

@ -1,165 +0,0 @@
package cmd
import (
"fmt"
"os"
"slices"
"sort"
"strings"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/creds"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/orchclient"
)
func newPluginTagsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tags",
Short: "Show current tags and popular tags from the registry",
RunE: func(c *cobra.Command, _ []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
if len(mod.Plugin.Tags) == 0 {
fmt.Println("Current tags: (none)")
} else {
fmt.Printf("Current tags: %s\n", strings.Join(mod.Plugin.Tags, ", "))
}
host, _ := c.Flags().GetString("host")
cr, err := creds.Load()
if err != nil {
return nil // not signed in is fine — silent best-effort
}
resolvedHost, hc, err := cr.Resolve(host)
if err != nil {
return nil
}
cli := orchclient.New(resolvedHost, hc.Token)
line := fetchPopularTagsForList(cli, mod.Plugin.Kind)
fmt.Println(line)
return nil
},
}
cmd.AddCommand(&cobra.Command{
Use: "add <tag>...",
Short: "Add tags to the local plugin.mod (union with current)",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("add", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "rm <tag>...",
Short: "Remove tags from the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("rm", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "set <tag>...",
Short: "Replace all tags in the local plugin.mod",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error { return mutateTags("set", args) },
})
cmd.AddCommand(&cobra.Command{
Use: "clear",
Short: "Remove all tags from the local plugin.mod",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error { return mutateTags("clear", nil) },
})
return cmd
}
// mutateTags reads plugin.mod, computes the new tag set, normalises, and writes
// it back. Prints the before→after diff and a reminder to publish.
func mutateTags(op string, args []string) error {
mod, err := readLocalMod()
if err != nil {
return err
}
before := append([]string(nil), mod.Plugin.Tags...)
var next []string
switch op {
case "add":
next = append(append([]string(nil), before...), args...)
case "rm":
drop := map[string]struct{}{}
for _, a := range args {
drop[strings.ToLower(strings.TrimSpace(a))] = struct{}{}
}
for _, t := range before {
if _, gone := drop[t]; !gone {
next = append(next, t)
}
}
case "set":
next = append([]string(nil), args...)
case "clear":
next = nil
default:
return fmt.Errorf("unknown tag op: %s", op)
}
normalised, err := core.NormalizeTags(next)
if err != nil {
return err
}
if err := writeLocalModTags(mod, normalised); err != nil {
return err
}
sortedBefore := append([]string(nil), before...)
sortedAfter := append([]string(nil), normalised...)
sort.Strings(sortedBefore)
sort.Strings(sortedAfter)
fmt.Printf("Tags: [%s] → [%s]\n", strings.Join(sortedBefore, ", "), strings.Join(sortedAfter, ", "))
if !slices.Equal(sortedBefore, sortedAfter) {
fmt.Println("Run 'ninja plugin publish' to push to the registry.")
}
return nil
}
func readLocalMod() (*core.ModFile, error) {
b, err := os.ReadFile("plugin.mod")
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(b)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
return mod, nil
}
// writeLocalModTags rewrites plugin.mod with the new tag set, preserving all
// other fields by reusing upsertPluginMod.
func writeLocalModTags(mod *core.ModFile, tags []string) error {
return upsertPluginMod(
mod.Plugin.Scope,
mod.Plugin.Name,
mod.Plugin.DisplayName,
mod.Plugin.Description,
mod.Plugin.Kind,
mod.Plugin.Categories,
tags,
mod.Plugin.Private,
)
}
// fetchPopularTagsForList returns a single user-facing line listing the most-used
// tags for the given kind. The current body returns an unavailable-notice
// because core's copy of the orchestrator proto bindings does not yet expose
// ListTags; once bindings are regenerated this body becomes a real
// PluginRegistryService.ListTags call rendering "Popular: tag (count), ..." or
// "Popular tags: (none yet)".
func fetchPopularTagsForList(cli *orchclient.Client, kind string) string {
_ = cli
_ = kind
return "(popular tags unavailable — orchestrator bindings not yet regenerated)"
}

View File

@ -548,78 +548,6 @@ func TestParsePrivateCoord(t *testing.T) {
}
}
func TestMutateTags_AddRmSetClear(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// Seed plugin.mod with no tags.
must(upsertPluginMod("themes", "darkpro", "Dark Pro", "Sleek dark theme", "theme", []string{}, nil, false))
// add
must(mutateTags("add", []string{"dark", "agency"}))
mod, err := readLocalMod()
must(err)
if len(mod.Plugin.Tags) != 2 || mod.Plugin.Tags[0] != "dark" || mod.Plugin.Tags[1] != "agency" {
t.Errorf("after add: %v", mod.Plugin.Tags)
}
// add (dedupe + normalise)
must(mutateTags("add", []string{"Agency", "Serif"}))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 3 {
t.Errorf("after dedupe add: %v", mod.Plugin.Tags)
}
// rm
must(mutateTags("rm", []string{"dark"}))
mod, _ = readLocalMod()
for _, tag := range mod.Plugin.Tags {
if tag == "dark" {
t.Errorf("after rm: dark still present: %v", mod.Plugin.Tags)
}
}
// set
must(mutateTags("set", []string{"editorial"}))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "editorial" {
t.Errorf("after set: %v", mod.Plugin.Tags)
}
// clear
must(mutateTags("clear", nil))
mod, _ = readLocalMod()
if len(mod.Plugin.Tags) != 0 {
t.Errorf("after clear: %v", mod.Plugin.Tags)
}
}
func TestMutateTags_RejectsInvalidNoWrite(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := upsertPluginMod("themes", "x", "X", "", "theme", nil, []string{"dark"}, false); err != nil {
t.Fatal(err)
}
if err := mutateTags("add", []string{"BAD SPACE"}); err == nil {
t.Fatal("expected validation error")
}
mod, err := readLocalMod()
if err != nil {
t.Fatal(err)
}
if len(mod.Plugin.Tags) != 1 || mod.Plugin.Tags[0] != "dark" {
t.Errorf("tags mutated despite error: %v", mod.Plugin.Tags)
}
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)

View File

@ -1,8 +1,6 @@
package plugin
import (
"fmt"
"regexp"
"strings"
tomlpkg "github.com/BurntSushi/toml"
@ -32,7 +30,6 @@ type ModPlugin struct {
Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
Categories []string `toml:"categories,omitempty"`
Tags []string `toml:"tags,omitempty"`
// RequiredIconPacks names icon-pack slugs the host CMS must ensure are
// installed before the plugin is loaded (e.g. "tabler", "phosphor"). The
// standalone-plugin loader honours this best-effort by auto-installing any
@ -89,54 +86,3 @@ func (m *ModFile) Coords() string {
// live. Coords for private plugins resolve to "@private/<name>@<version>";
// uniqueness is enforced by (owner_account_id, name), not by the slug.
const PrivateScopeSlug = "private"
const (
TagMinLen = 2
TagMaxLen = 30
TagMaxCount = 10
)
// tagSlugRe matches lowercase a-z, 0-9, with single hyphens between groups.
// Rejects leading/trailing/consecutive hyphens.
var tagSlugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// NormalizeTags trims, lowercases, dedupes (case-insensitively), validates,
// and caps a slice of tags. Returns the cleaned slice or an error listing
// every offending input so authors fix them in one pass.
//
// Rules:
// - trim surrounding whitespace; drop empty entries silently
// - lowercase
// - require [a-z0-9-]{TagMinLen..TagMaxLen}, no leading/trailing/consecutive hyphens
// - dedupe case-insensitively, preserving first occurrence order
// - at most TagMaxCount entries (counted after dedupe)
func NormalizeTags(in []string) ([]string, error) {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
var bad []string
for _, raw := range in {
t := strings.ToLower(strings.TrimSpace(raw))
if t == "" {
continue
}
if _, dup := seen[t]; dup {
continue
}
if len(t) < TagMinLen || len(t) > TagMaxLen || !tagSlugRe.MatchString(t) {
bad = append(bad, raw)
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
if len(bad) > 0 {
return nil, fmt.Errorf(
"invalid tags (must be %d-%d chars, lowercase a-z 0-9 and single hyphens, no leading/trailing hyphen): %s",
TagMinLen, TagMaxLen, strings.Join(bad, ", "),
)
}
if len(out) > TagMaxCount {
return nil, fmt.Errorf("too many tags: got %d, max %d", len(out), TagMaxCount)
}
return out, nil
}

View File

@ -1,8 +1,6 @@
package plugin
import (
"fmt"
"strings"
"testing"
)
@ -273,131 +271,3 @@ version = ">=1.2"
t.Errorf("Requires[1].Version = %q", m.Requires[1].Version)
}
}
func TestNormalizeTags_HappyPath(t *testing.T) {
got, err := NormalizeTags([]string{"dark", "agency", "serif"})
if err != nil {
t.Fatalf("NormalizeTags err: %v", err)
}
want := []string{"dark", "agency", "serif"}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestNormalizeTags_LowercaseAndTrim(t *testing.T) {
got, err := NormalizeTags([]string{" Dark ", "AGENCY"})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 2 || got[0] != "dark" || got[1] != "agency" {
t.Errorf("got %v, want [dark agency]", got)
}
}
func TestNormalizeTags_DedupesCaseInsensitive(t *testing.T) {
got, err := NormalizeTags([]string{"dark", "Dark", "DARK", "agency"})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 2 || got[0] != "dark" || got[1] != "agency" {
t.Errorf("got %v, want [dark agency]", got)
}
}
func TestNormalizeTags_DropsEmpty(t *testing.T) {
got, err := NormalizeTags([]string{"", "dark", " "})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 1 || got[0] != "dark" {
t.Errorf("got %v, want [dark]", got)
}
}
func TestNormalizeTags_RejectsBadSlugs(t *testing.T) {
_, err := NormalizeTags([]string{"valid", "Has Space", "trailing-", "-leading", "double--hyphen", "a", strings.Repeat("x", 31)})
if err == nil {
t.Fatal("expected error")
}
for _, frag := range []string{"Has Space", "trailing-", "-leading", "double--hyphen", "a", strings.Repeat("x", 31)} {
if !strings.Contains(err.Error(), frag) {
t.Errorf("error %q does not mention %q", err.Error(), frag)
}
}
}
func TestNormalizeTags_AcceptsBounds(t *testing.T) {
got, err := NormalizeTags([]string{"ab", strings.Repeat("a", 30)})
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 2 {
t.Errorf("got %v, want both accepted", got)
}
}
func TestNormalizeTags_CapEnforced(t *testing.T) {
in := make([]string, TagMaxCount+1)
for i := range in {
in[i] = fmt.Sprintf("tag-%d", i)
}
_, err := NormalizeTags(in)
if err == nil {
t.Fatal("expected too-many-tags error")
}
if !strings.Contains(err.Error(), "too many tags") {
t.Errorf("error %q does not mention 'too many tags'", err.Error())
}
}
func TestNormalizeTags_NilInput(t *testing.T) {
got, err := NormalizeTags(nil)
if err != nil {
t.Fatalf("err: %v", err)
}
if len(got) != 0 {
t.Errorf("got %v, want empty", got)
}
}
func TestParseModFull_Tags(t *testing.T) {
src := []byte(`
[plugin]
name = "dark-pro"
scope = "themes"
version = "0.1.0"
kind = "theme"
tags = ["dark", "agency", "serif"]
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("ParseModFull err: %v", err)
}
if len(m.Plugin.Tags) != 3 {
t.Fatalf("Tags len = %d, want 3 (%v)", len(m.Plugin.Tags), m.Plugin.Tags)
}
if m.Plugin.Tags[0] != "dark" || m.Plugin.Tags[1] != "agency" || m.Plugin.Tags[2] != "serif" {
t.Errorf("Tags = %v", m.Plugin.Tags)
}
}
func TestParseModFull_TagsOmittedIsNil(t *testing.T) {
src := []byte(`
[plugin]
name = "no-tags"
version = "0.1.0"
`)
m, err := ParseModFull(src)
if err != nil {
t.Fatalf("err: %v", err)
}
if m.Plugin.Tags != nil {
t.Errorf("Tags = %v, want nil when omitted", m.Plugin.Tags)
}
}