Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa66c2f409 | ||
|
|
f4e579ad7a | ||
|
|
26b262ce73 | ||
|
|
bb3ddfe1bd | ||
|
|
5d368da839 | ||
|
|
e16655aed8 | ||
|
|
03d32aba26 | ||
|
|
533632a3bb | ||
|
|
6bc0f98979 | ||
|
|
d53c3d8325 | ||
|
|
ed365f9030 | ||
|
|
ba87684696 | ||
|
|
c3c7b2d441 | ||
|
|
48c54814ec | ||
|
|
af7f44c34d | ||
|
|
7fc20a990b | ||
|
|
051253396c | ||
|
|
35436581b9 | ||
|
|
c390e16b5c | ||
|
|
87910e22ff | ||
|
|
c4d00a11d9 | ||
|
|
7615bd92ca | ||
|
|
264116f44e | ||
|
|
06cabd6eb9 | ||
|
|
041a7c2e3f | ||
|
|
dae3aa918a | ||
|
|
46e3389045 | ||
|
|
824d55a1fa | ||
|
|
ea744888ae | ||
|
|
3d62071f77 | ||
|
|
e076a03c33 | ||
|
|
fda01e81b5 | ||
|
|
421f5ee0cb | ||
|
|
ee76d76dc6 | ||
|
|
ab465ef07c | ||
|
|
137a50c932 | ||
|
|
c3cfa18ae0 | ||
|
|
4c0104619e | ||
|
|
20a7b35e50 | ||
|
|
7af42c1c83 | ||
|
|
d1c194ce66 | ||
|
|
139d9b8543 | ||
|
|
12afdbd25e | ||
|
|
f232effe69 | ||
|
|
a79aa709c2 | ||
|
|
08be22ec34 | ||
|
|
aafdc44f6f | ||
|
|
57a217f54d | ||
|
|
c825942c8d | ||
|
|
e5b27f5a65 | ||
|
|
a827cda37a | ||
|
|
31e7b72b49 | ||
|
|
680cbe0160 | ||
|
|
e9bef5b065 | ||
|
|
2a76b30c51 | ||
|
|
1d9ca44f55 | ||
|
|
32c6528162 | ||
|
|
7f4bce79c9 | ||
|
|
7ff326ef25 | ||
|
|
a5caf2d9e7 | ||
|
|
245e38dc95 | ||
|
|
7eb3e27053 | ||
|
|
9c62780246 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "proto"]
|
||||||
|
path = proto
|
||||||
|
url = git@git.dev.alexdunmow.com:block/proto.git
|
||||||
9
CLAUDE.md
Normal file
9
CLAUDE.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Core SDK
|
||||||
|
|
||||||
|
Go module `git.dev.alexdunmow.com/block/core`. Defines plugin interfaces, template engine, block registry, and shared types for the BlockNinja CMS.
|
||||||
|
|
||||||
|
## Critical Rules
|
||||||
|
|
||||||
|
- **NEVER use `replace` directives in go.mod** — not in this repo, not in any consumer. All module resolution goes through the Gitea module proxy. If you need to test local changes, tag and push a version.
|
||||||
|
- Plugins import from core only — never from the CMS (`blockninja/backend`) or orchestrator.
|
||||||
|
- All consumers are in-house — no backwards compatibility shims needed. Just change the API and update consumers.
|
||||||
138
Makefile
Normal file
138
Makefile
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
SDK_MODULE := git.dev.alexdunmow.com/block/core
|
||||||
|
SDK_VERSION ?= $(shell git describe --tags --abbrev=0)
|
||||||
|
|
||||||
|
SDK_DOWNSTREAM_DIRS := \
|
||||||
|
$(HOME)/src/blockninja/backend \
|
||||||
|
$(HOME)/src/orchestrator/backend \
|
||||||
|
$(wildcard $(HOME)/src/blockninja-themes/*) \
|
||||||
|
$(HOME)/src/assumechaos \
|
||||||
|
$(HOME)/src/bidbuddy \
|
||||||
|
$(HOME)/src/bidmasters \
|
||||||
|
$(HOME)/src/coterieos \
|
||||||
|
$(HOME)/src/messenger \
|
||||||
|
$(HOME)/src/perthplaygrounds \
|
||||||
|
$(HOME)/src/symposium
|
||||||
|
|
||||||
|
.PHONY: install-ninja
|
||||||
|
install-ninja:
|
||||||
|
go install ./cmd/ninja
|
||||||
|
|
||||||
|
# Regenerate Go bindings from the proto/ submodule. We narrow to
|
||||||
|
# plugin_registry.proto because other orchestrator/v1 protos (accounts.proto
|
||||||
|
# etc.) are owned by the orchestrator's generated package; registering them
|
||||||
|
# from core too would panic at startup with "file ... is already registered".
|
||||||
|
.PHONY: proto
|
||||||
|
proto:
|
||||||
|
buf generate --path proto/orchestrator/v1/plugin_registry.proto
|
||||||
|
|
||||||
|
.PHONY: update-sdk
|
||||||
|
update-sdk:
|
||||||
|
@set -e; \
|
||||||
|
for dir in $(SDK_DOWNSTREAM_DIRS); do \
|
||||||
|
if [ ! -f "$$dir/go.mod" ]; then \
|
||||||
|
echo "skip $$dir (no go.mod)"; \
|
||||||
|
continue; \
|
||||||
|
fi; \
|
||||||
|
echo "==> $$dir: $(SDK_MODULE)@$(SDK_VERSION)"; \
|
||||||
|
( \
|
||||||
|
cd "$$dir"; \
|
||||||
|
go mod edit -dropreplace=$(SDK_MODULE) 2>/dev/null || true; \
|
||||||
|
go get $(SDK_MODULE)@$(SDK_VERSION); \
|
||||||
|
go mod tidy; \
|
||||||
|
if grep -q '^replace $(SDK_MODULE)' go.mod; then \
|
||||||
|
echo "replace directive still present in $$dir/go.mod" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
); \
|
||||||
|
done
|
||||||
|
|
||||||
|
# Cut a new SDK release: tag, push, bump every downstream's pin, and commit
|
||||||
|
# + push the pin bump in each downstream.
|
||||||
|
#
|
||||||
|
# Defaults: version is auto-bumped from the last vX.Y.Z tag. Level is
|
||||||
|
# inferred from conventional-commits since that tag: BREAKING/`!:` → major,
|
||||||
|
# `feat` → minor, otherwise → patch.
|
||||||
|
#
|
||||||
|
# Overrides:
|
||||||
|
# make release # auto-bump (recommended)
|
||||||
|
# make release LEVEL=minor # force level
|
||||||
|
# make release VERSION=v1.2.3 # explicit version
|
||||||
|
.PHONY: release
|
||||||
|
release:
|
||||||
|
@set -e; \
|
||||||
|
if [ -n "$(VERSION)" ]; then \
|
||||||
|
case "$(VERSION)" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "VERSION must look like vX.Y.Z (got $(VERSION))" >&2; exit 1 ;; esac; \
|
||||||
|
next="$(VERSION)"; \
|
||||||
|
else \
|
||||||
|
current=$$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null | sed 's/^v//'); \
|
||||||
|
if [ -z "$$current" ]; then echo "no existing vX.Y.Z tag; pass VERSION=vX.Y.Z" >&2; exit 1; fi; \
|
||||||
|
commits=$$(git log "v$$current..HEAD" --pretty=format:%s); \
|
||||||
|
if [ -z "$$commits" ]; then echo "no commits since v$$current; nothing to release" >&2; exit 1; fi; \
|
||||||
|
level=$${LEVEL:-}; \
|
||||||
|
if [ -z "$$level" ]; then \
|
||||||
|
if echo "$$commits" | grep -qE "(^|[[:space:]])(BREAKING[ -]CHANGE|[a-z]+(\([^)]+\))?!:)"; then level=major; \
|
||||||
|
elif echo "$$commits" | grep -qE "^feat(\(|:)"; then level=minor; \
|
||||||
|
else level=patch; fi; \
|
||||||
|
echo "==> auto-detected $$level bump from conventional-commits since v$$current"; \
|
||||||
|
fi; \
|
||||||
|
major=$$(echo $$current | cut -d. -f1); \
|
||||||
|
minor=$$(echo $$current | cut -d. -f2); \
|
||||||
|
patch=$$(echo $$current | cut -d. -f3); \
|
||||||
|
case "$$level" in \
|
||||||
|
patch) patch=$$((patch+1)) ;; \
|
||||||
|
minor) minor=$$((minor+1)); patch=0 ;; \
|
||||||
|
major) major=$$((major+1)); minor=0; patch=0 ;; \
|
||||||
|
*) echo "LEVEL must be patch|minor|major (got $$level)" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
next="v$$major.$$minor.$$patch"; \
|
||||||
|
echo "==> bumping v$$current -> $$next"; \
|
||||||
|
fi; \
|
||||||
|
if ! git diff-index --quiet HEAD --; then echo "working tree dirty; commit before releasing" >&2; exit 1; fi; \
|
||||||
|
git push origin HEAD; \
|
||||||
|
git tag -a "$$next" -m "release $$next"; \
|
||||||
|
git push origin "$$next"; \
|
||||||
|
$(MAKE) update-sdk SDK_VERSION=$$next; \
|
||||||
|
$(MAKE) distribute-sdk SDK_VERSION=$$next; \
|
||||||
|
echo "==> $$next tagged, pushed, and distributed."
|
||||||
|
|
||||||
|
# Commit + push the SDK pin bump in each downstream. Surgical: only commits
|
||||||
|
# go.mod / go.sum so any unrelated WIP in the downstream is left alone (and
|
||||||
|
# unpushed). Repos without an `origin` remote are committed locally only.
|
||||||
|
.PHONY: distribute-sdk
|
||||||
|
distribute-sdk:
|
||||||
|
@set -e; \
|
||||||
|
if [ -z "$(SDK_VERSION)" ]; then echo "usage: make distribute-sdk SDK_VERSION=vX.Y.Z" >&2; exit 1; fi; \
|
||||||
|
for dir in $(SDK_DOWNSTREAM_DIRS); do \
|
||||||
|
if [ ! -f "$$dir/go.mod" ]; then continue; fi; \
|
||||||
|
( \
|
||||||
|
cd "$$dir"; \
|
||||||
|
if git diff --quiet HEAD -- go.mod go.sum; then \
|
||||||
|
exit 0; \
|
||||||
|
fi; \
|
||||||
|
echo "==> $$dir: commit + push pin bump"; \
|
||||||
|
git add -- go.mod go.sum; \
|
||||||
|
git commit -m "chore: bump core to $(SDK_VERSION)" -- go.mod go.sum >/dev/null; \
|
||||||
|
if git config --get remote.origin.url >/dev/null 2>&1; then \
|
||||||
|
git push origin HEAD 2>&1 | tail -1; \
|
||||||
|
else \
|
||||||
|
echo " (no origin remote — committed locally only)"; \
|
||||||
|
fi; \
|
||||||
|
); \
|
||||||
|
done
|
||||||
|
|
||||||
|
.PHONY: check-sdk-pins
|
||||||
|
check-sdk-pins:
|
||||||
|
@set -e; \
|
||||||
|
for dir in $(SDK_DOWNSTREAM_DIRS); do \
|
||||||
|
if [ ! -f "$$dir/go.mod" ]; then \
|
||||||
|
continue; \
|
||||||
|
fi; \
|
||||||
|
if grep -q '^replace $(SDK_MODULE)' "$$dir/go.mod"; then \
|
||||||
|
echo "replace directive found in $$dir/go.mod" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
if ! grep -Eq '^[[:space:]]*(require[[:space:]]+)?$(SDK_MODULE)[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+' "$$dir/go.mod"; then \
|
||||||
|
echo "$(SDK_MODULE) is not pinned in $$dir/go.mod" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
@ -6,5 +6,7 @@ import "io/fs"
|
|||||||
type BlockRegistry interface {
|
type BlockRegistry interface {
|
||||||
Register(meta BlockMeta, fn BlockFunc)
|
Register(meta BlockMeta, fn BlockFunc)
|
||||||
RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc)
|
RegisterTemplateOverride(templateKey, blockKey string, fn BlockFunc)
|
||||||
|
RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn BlockFunc)
|
||||||
LoadSchemasFromFS(fsys fs.FS) error
|
LoadSchemasFromFS(fsys fs.FS) error
|
||||||
|
LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error
|
||||||
}
|
}
|
||||||
|
|||||||
15
buf.gen.yaml
Normal file
15
buf.gen.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: v2
|
||||||
|
managed:
|
||||||
|
enabled: true
|
||||||
|
override:
|
||||||
|
- file_option: go_package_prefix
|
||||||
|
value: git.dev.alexdunmow.com/block/core/internal/api
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-go
|
||||||
|
out: internal/api
|
||||||
|
opt:
|
||||||
|
- paths=source_relative
|
||||||
|
- local: protoc-gen-connect-go
|
||||||
|
out: internal/api
|
||||||
|
opt:
|
||||||
|
- paths=source_relative
|
||||||
19
buf.yaml
Normal file
19
buf.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
version: v2
|
||||||
|
# proto/ is a git submodule pointing at block/proto. We only need
|
||||||
|
# plugin_registry.proto for the CLI in this module; the rest of orchestrator/v1
|
||||||
|
# is generated by the orchestrator from its own copy and would panic at startup
|
||||||
|
# with "file ... is already registered" if both packages registered the same
|
||||||
|
# descriptors. buf doesn't support per-file include/exclude in the module
|
||||||
|
# config, so the narrowing happens via `buf generate --path` invoked from the
|
||||||
|
# Makefile (see the `proto` target).
|
||||||
|
modules:
|
||||||
|
- path: proto
|
||||||
|
lint:
|
||||||
|
use:
|
||||||
|
- STANDARD
|
||||||
|
except:
|
||||||
|
- PACKAGE_SAME_GO_PACKAGE
|
||||||
|
- RPC_REQUEST_RESPONSE_UNIQUE
|
||||||
|
breaking:
|
||||||
|
use:
|
||||||
|
- FILE
|
||||||
150
cmd/ninja/cmd/account.go
Normal file
150
cmd/ninja/cmd/account.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"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 newAccountCmd() *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "account",
|
||||||
|
Short: "Manage which account ninja acts as",
|
||||||
|
Long: `Account-scoped commands like ` + "`ninja plugins publish --private`" + ` act
|
||||||
|
against an "active account" — the orchestrator-side account whose members
|
||||||
|
can see and install the plugin. The active account is selected at
|
||||||
|
` + "`ninja login`" + ` time and persisted in your credentials file.`,
|
||||||
|
}
|
||||||
|
c.AddCommand(newAccountListCmd(), newAccountSetCmd(), newAccountShowCmd())
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List the accounts the authenticated user belongs to",
|
||||||
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
|
cli, _, _, hc, err := resolveClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(),
|
||||||
|
connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list accounts: %w", err)
|
||||||
|
}
|
||||||
|
if len(accts.Msg.Accounts) == 0 {
|
||||||
|
fmt.Println("No accounts.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, a := range accts.Msg.Accounts {
|
||||||
|
marker := " "
|
||||||
|
if a.Id == hc.ActiveAccountID {
|
||||||
|
marker = "* "
|
||||||
|
}
|
||||||
|
fmt.Printf("%s%s — %s\n", marker, a.Slug, a.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountSetCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "set <slug>",
|
||||||
|
Short: "Change the active account",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(c *cobra.Command, args []string) error {
|
||||||
|
cli, cr, host, hc, err := resolveClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slug := strings.TrimPrefix(args[0], "@")
|
||||||
|
accts, err := cli.Auth.ListMyAccountsForCLI(context.Background(),
|
||||||
|
connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list accounts: %w", err)
|
||||||
|
}
|
||||||
|
for _, a := range accts.Msg.Accounts {
|
||||||
|
if a.Slug == slug {
|
||||||
|
hc.ActiveAccountID = a.Id
|
||||||
|
hc.ActiveAccountSlug = a.Slug
|
||||||
|
cr.Hosts[host] = hc
|
||||||
|
if err := cr.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Active account: %s (%s)\n", a.Slug, a.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("account %q not found among your memberships; try `ninja account list`", slug)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountShowCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Show the currently active account",
|
||||||
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
|
_, _, _, hc, err := resolveClient(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hc.ActiveAccountSlug == "" {
|
||||||
|
fmt.Println("(no active account set; run `ninja login` or `ninja account set <slug>`)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Active account: %s (id=%s)\n", hc.ActiveAccountSlug, hc.ActiveAccountID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveClient is a small helper used by every `ninja account` subcommand:
|
||||||
|
// it loads creds, resolves the host, and returns an authed client plus the
|
||||||
|
// loaded credentials so the caller can persist changes.
|
||||||
|
func resolveClient(c *cobra.Command) (*orchclient.Client, *creds.Credentials, string, creds.HostCreds, error) {
|
||||||
|
host, _ := c.Flags().GetString("host")
|
||||||
|
cr, err := creds.Load()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", creds.HostCreds{}, err
|
||||||
|
}
|
||||||
|
resolvedHost, hc, err := cr.Resolve(host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", creds.HostCreds{}, err
|
||||||
|
}
|
||||||
|
return orchclient.New(resolvedHost, hc.Token), cr, resolvedHost, hc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickAccountInteractive prompts the user to select an account by number from
|
||||||
|
// the given list and returns the chosen account. Used by `ninja login` when
|
||||||
|
// the user belongs to more than one account.
|
||||||
|
func pickAccountInteractive(scanner *bufio.Scanner, accounts []*v1.MyAccount) (*v1.MyAccount, error) {
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
return nil, fmt.Errorf("no accounts available")
|
||||||
|
}
|
||||||
|
fmt.Println("Select an account:")
|
||||||
|
for i, a := range accounts {
|
||||||
|
fmt.Printf(" %d) %s — %s\n", i+1, a.Slug, a.Name)
|
||||||
|
}
|
||||||
|
fmt.Print("> ")
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return nil, fmt.Errorf("cancelled")
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(scanner.Text())
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 || n > len(accounts) {
|
||||||
|
return nil, fmt.Errorf("invalid selection: %s", v)
|
||||||
|
}
|
||||||
|
return accounts[n-1], nil
|
||||||
|
}
|
||||||
150
cmd/ninja/cmd/login.go
Normal file
150
cmd/ninja/cmd/login.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 newLoginCmd() *cobra.Command {
|
||||||
|
var host string
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Short: "Authenticate against the orchestrator using device flow",
|
||||||
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
|
if host == "" {
|
||||||
|
host, _ = c.Flags().GetString("host")
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
host = "https://my.blockninjacms.com"
|
||||||
|
}
|
||||||
|
cli := orchclient.New(host, "")
|
||||||
|
ctx := context.Background()
|
||||||
|
start, err := cli.Auth.StartDevice(ctx, connect.NewRequest(&v1.StartDeviceRequest{
|
||||||
|
Scopes: []string{"plugin:read", "plugin:publish", "scope:admin"},
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start device: %w", err)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
time.Sleep(interval)
|
||||||
|
poll, err := cli.Auth.PollDevice(ctx, connect.NewRequest(&v1.PollDeviceRequest{DeviceCode: start.Msg.DeviceCode}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch poll.Msg.Status {
|
||||||
|
case "pending":
|
||||||
|
continue
|
||||||
|
case "approved":
|
||||||
|
cr, err := creds.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cr.DefaultHost = host
|
||||||
|
if cr.Hosts == nil {
|
||||||
|
cr.Hosts = map[string]creds.HostCreds{}
|
||||||
|
}
|
||||||
|
hc := creds.HostCreds{Token: poll.Msg.AccessToken}
|
||||||
|
authed := orchclient.New(host, hc.Token)
|
||||||
|
if err := selectActiveAccount(ctx, authed, &hc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cr.Hosts[host] = hc
|
||||||
|
if err := cr.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Logged in.")
|
||||||
|
return nil
|
||||||
|
case "expired":
|
||||||
|
return fmt.Errorf("device code expired; try again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("login timed out")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().StringVar(&host, "host", "", "Orchestrator base URL")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWhoamiCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "whoami",
|
||||||
|
Short: "Show the currently logged-in user",
|
||||||
|
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)
|
||||||
|
r, err := cli.Auth.Whoami(context.Background(), connect.NewRequest(&v1.WhoamiRequest{}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s <%s> at %s\n", r.Msg.DisplayName, r.Msg.Email, resolvedHost)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectActiveAccount fetches the user's accounts and writes the active one
|
||||||
|
// into hc. With 0 accounts it errors (the server contract guarantees every
|
||||||
|
// user has at least one). With 1 it auto-selects silently. With ≥2 it
|
||||||
|
// prompts interactively on stdin.
|
||||||
|
func selectActiveAccount(ctx context.Context, cli *orchclient.Client, hc *creds.HostCreds) error {
|
||||||
|
resp, err := cli.Auth.ListMyAccountsForCLI(ctx, connect.NewRequest(&v1.ListMyAccountsForCLIRequest{}))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list accounts: %w", err)
|
||||||
|
}
|
||||||
|
accts := resp.Msg.Accounts
|
||||||
|
switch len(accts) {
|
||||||
|
case 0:
|
||||||
|
return fmt.Errorf("no accounts found for this user; contact support")
|
||||||
|
case 1:
|
||||||
|
hc.ActiveAccountID = accts[0].Id
|
||||||
|
hc.ActiveAccountSlug = accts[0].Slug
|
||||||
|
fmt.Printf("Active account: %s (%s)\n", accts[0].Slug, accts[0].Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
chosen, err := pickAccountInteractive(bufio.NewScanner(os.Stdin), accts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hc.ActiveAccountID = chosen.Id
|
||||||
|
hc.ActiveAccountSlug = chosen.Slug
|
||||||
|
fmt.Printf("Active account: %s (%s)\n", chosen.Slug, chosen.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLogoutCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "logout",
|
||||||
|
Short: "Remove stored credentials",
|
||||||
|
RunE: func(c *cobra.Command, _ []string) error {
|
||||||
|
host, _ := c.Flags().GetString("host")
|
||||||
|
cr, err := creds.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolvedHost, _, _ := cr.Resolve(host)
|
||||||
|
delete(cr.Hosts, resolvedHost)
|
||||||
|
return cr.Save()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
1213
cmd/ninja/cmd/plugin.go
Normal file
1213
cmd/ninja/cmd/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
175
cmd/ninja/cmd/plugin_tags.go
Normal file
175
cmd/ninja/cmd/plugin_tags.go
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"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"
|
||||||
|
v1 "git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
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. Renders "Popular: tag (count), ...", "Popular tags:
|
||||||
|
// (none yet)" when no tags exist on public plugins yet, or an "(unreachable)"
|
||||||
|
// notice if the RPC fails.
|
||||||
|
func fetchPopularTagsForList(cli *orchclient.Client, kind string) string {
|
||||||
|
resp, err := cli.Reg.ListTags(context.Background(), connect.NewRequest(&v1.ListTagsRequest{Kind: kind, Limit: 20}))
|
||||||
|
if err != nil {
|
||||||
|
return "(could not fetch popular tags — orchestrator unreachable)"
|
||||||
|
}
|
||||||
|
if len(resp.Msg.Tags) == 0 {
|
||||||
|
return "Popular tags: (none yet)"
|
||||||
|
}
|
||||||
|
parts := make([]string, len(resp.Msg.Tags))
|
||||||
|
for i, t := range resp.Msg.Tags {
|
||||||
|
parts[i] = fmt.Sprintf("%s (%d)", t.Tag, t.Count)
|
||||||
|
}
|
||||||
|
return "Popular: " + strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
637
cmd/ninja/cmd/plugin_test.go
Normal file
637
cmd/ninja/cmd/plugin_test.go
Normal file
@ -0,0 +1,637 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
core "git.dev.alexdunmow.com/block/core/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckRepoHasHEAD_NoCommitsReturnsFriendlyError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
|
||||||
|
err := checkRepoHasHEAD(dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for repo with no commits, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no commits in repository") {
|
||||||
|
t.Errorf("error %q should mention 'no commits in repository'", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "git commit") {
|
||||||
|
t.Errorf("error %q should suggest `git commit`", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRepoHasHEAD_WithCommitReturnsNil(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "f")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
if err := checkRepoHasHEAD(dir); err != nil {
|
||||||
|
t.Errorf("expected nil for repo with a commit, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCommitPluginMod_CommitsWhenDirty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
|
||||||
|
t.Fatalf("autoCommitPluginMod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := gitLogSubject(t, dir)
|
||||||
|
if subject != "Add plugin.mod" {
|
||||||
|
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCommitPluginMod_NoopWhenClean(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "plugin.mod")
|
||||||
|
runGit(t, dir, "commit", "-qm", "seed")
|
||||||
|
|
||||||
|
beforeSHA := gitHeadSHA(t, dir)
|
||||||
|
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
|
||||||
|
t.Fatalf("autoCommitPluginMod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterSHA := gitHeadSHA(t, dir)
|
||||||
|
if afterSHA != beforeSHA {
|
||||||
|
t.Errorf("expected no new commit, HEAD moved %s -> %s", beforeSHA, afterSHA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCommitPluginMod_WorksOnDetachedHEAD(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
initialSHA := gitHeadSHA(t, dir)
|
||||||
|
runGit(t, dir, "checkout", "-q", initialSHA)
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := autoCommitPluginMod("Add plugin.mod"); err != nil {
|
||||||
|
t.Fatalf("autoCommitPluginMod on detached HEAD: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterSHA := gitHeadSHA(t, dir)
|
||||||
|
if afterSHA == initialSHA {
|
||||||
|
t.Fatalf("expected new commit on detached HEAD, HEAD still at %s", afterSHA)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := gitLogSubject(t, dir)
|
||||||
|
if subject != "Add plugin.mod" {
|
||||||
|
t.Errorf("expected latest commit subject 'Add plugin.mod', got %q", subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentCmd := exec.Command("git", "rev-parse", "HEAD^")
|
||||||
|
parentCmd.Dir = dir
|
||||||
|
parentOut, err := parentCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git rev-parse HEAD^: %v\n%s", err, parentOut)
|
||||||
|
}
|
||||||
|
parentSHA := strings.TrimSpace(string(parentOut))
|
||||||
|
if parentSHA != initialSHA {
|
||||||
|
t.Errorf("expected new commit parent to be %s, got %s", initialSHA, parentSHA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCommitPluginMod_UsesProvidedMessage(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
|
||||||
|
t.Fatalf("autoCommitPluginMod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := gitLogSubject(t, dir); got != "bump to 0.3.0" {
|
||||||
|
t.Errorf("expected commit subject 'bump to 0.3.0', got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCommitPluginMod_LeavesOtherStagedPathsAlone(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
// Stage an unrelated change that publish should NOT sweep up into the
|
||||||
|
// plugin.mod auto-commit.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "other.txt"), []byte("scratch"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "other.txt")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.3.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Chdir(dir)
|
||||||
|
if err := autoCommitPluginMod("bump to 0.3.0"); err != nil {
|
||||||
|
t.Fatalf("autoCommitPluginMod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The new commit should touch plugin.mod only.
|
||||||
|
filesCmd := exec.Command("git", "show", "--name-only", "--pretty=", "HEAD")
|
||||||
|
filesCmd.Dir = dir
|
||||||
|
filesOut, err := filesCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git show: %v\n%s", err, filesOut)
|
||||||
|
}
|
||||||
|
files := strings.Fields(strings.TrimSpace(string(filesOut)))
|
||||||
|
if len(files) != 1 || files[0] != "plugin.mod" {
|
||||||
|
t.Errorf("expected commit to touch only plugin.mod, got %v", files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// other.txt should still be staged (waiting for the developer to deal with).
|
||||||
|
statusCmd := exec.Command("git", "status", "--porcelain", "other.txt")
|
||||||
|
statusCmd.Dir = dir
|
||||||
|
statusOut, _ := statusCmd.Output()
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(string(statusOut)), "A ") {
|
||||||
|
t.Errorf("expected other.txt to remain staged ('A '), got %q", string(statusOut))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoCommitPluginMod_ErrorsWhenGitMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname = \"x\"\nscope = \"@s\"\nversion = \"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Chdir(dir)
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
err := autoCommitPluginMod("Add plugin.mod")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when git is missing from PATH, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "git") {
|
||||||
|
t.Errorf("error %q should mention 'git'", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitLogSubject(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", "log", "-1", "--pretty=%s")
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git log: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitHeadSHA(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", "rev-parse", "HEAD")
|
||||||
|
cmd.Dir = dir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git rev-parse: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitignoredTrackedWarning_FiresWhenTrackedFileMatchesGitignore(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "secret.env")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", ".gitignore")
|
||||||
|
runGit(t, dir, "commit", "-qm", "ignore")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gitignoredTrackedWarning(dir, &buf)
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "secret.env") {
|
||||||
|
t.Errorf("warning should list secret.env, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "git rm --cached") {
|
||||||
|
t.Errorf("warning should suggest `git rm --cached`, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out, "\n") {
|
||||||
|
t.Errorf("warning should end with a newline (Fprintln), got: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitignoredTrackedWarning_NoopWhenNothingMatches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gitignoredTrackedWarning(dir, &buf)
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected empty output for clean repo, got: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUntrackedFilesWarning_FiresWithUntrackedFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("scratch"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
untrackedFilesWarning(dir, &buf)
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "notes.txt") {
|
||||||
|
t.Errorf("warning should list notes.txt, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "git add") {
|
||||||
|
t.Errorf("warning should suggest `git add`, got: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUntrackedFilesWarning_NoopWithNoUntracked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
untrackedFilesWarning(dir, &buf)
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected empty output, got: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmoduleWarning_FiresWithGitmodules(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
gitmodules := `[submodule "vendor/foo"]
|
||||||
|
path = vendor/foo
|
||||||
|
url = https://example.com/foo.git
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
submoduleWarning(dir, &buf)
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "vendor/foo") {
|
||||||
|
t.Errorf("warning should mention vendor/foo, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "submodules") {
|
||||||
|
t.Errorf("warning should mention submodules, got: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmoduleWarning_NoopWithoutGitmodules(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
submoduleWarning(dir, &buf)
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected empty output, got: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitPublishWarnings_WarnsAboutGitignoreTracked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "secret.env"), []byte("token=abc"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "secret.env")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.env\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", ".gitignore")
|
||||||
|
runGit(t, dir, "commit", "-qm", "ignore")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := emitPublishWarnings(dir, false, &buf); err != nil {
|
||||||
|
t.Fatalf("emitPublishWarnings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "secret.env") {
|
||||||
|
t.Errorf("expected gitignored-tracked warning to mention secret.env, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "match .gitignore") {
|
||||||
|
t.Errorf("expected gitignored-tracked warning fragment, got: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitPublishWarnings_WarnsAboutSubmodules(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
gitmodules := `[submodule "vendor/foo"]
|
||||||
|
path = vendor/foo
|
||||||
|
url = https://example.com/foo.git
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gitmodules"), []byte(gitmodules), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", ".gitmodules")
|
||||||
|
runGit(t, dir, "commit", "-qm", "add submodule decl")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := emitPublishWarnings(dir, false, &buf); err != nil {
|
||||||
|
t.Fatalf("emitPublishWarnings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "vendor/foo") {
|
||||||
|
t.Errorf("expected submodule warning to mention vendor/foo, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "submodules") {
|
||||||
|
t.Errorf("expected submodule warning fragment, got: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmitPublishWarnings_WarnsAboutUntrackedWithAllowDirty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGit(t, dir, "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGit(t, dir, "add", "README.md")
|
||||||
|
runGit(t, dir, "commit", "-qm", "init")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "scratch.txt"), []byte("notes"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("allowDirty=true surfaces untracked warning", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := emitPublishWarnings(dir, true, &buf); err != nil {
|
||||||
|
t.Fatalf("emitPublishWarnings: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "scratch.txt") {
|
||||||
|
t.Errorf("expected untracked-files warning to mention scratch.txt, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "NOT be in the archive") {
|
||||||
|
t.Errorf("expected untracked-files warning fragment, got: %q", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowDirty=false aborts before untracked warning", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := emitPublishWarnings(dir, false, &buf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected dirty-tree error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "working tree dirty") {
|
||||||
|
t.Errorf("expected dirty-tree error, got: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--strict") {
|
||||||
|
t.Errorf("expected dirty-tree error to reference --strict, got: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), "untracked files will NOT be in the archive") {
|
||||||
|
t.Errorf("untracked-files warning should not fire on dirty-abort path, got: %q", buf.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteMod_PrivateTrueSerializes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "plugin.mod")
|
||||||
|
m := &core.ModFile{Plugin: core.ModPlugin{
|
||||||
|
Name: "myplugin",
|
||||||
|
Scope: "themes",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Private: true,
|
||||||
|
}}
|
||||||
|
if err := writeMod(path, m); err != nil {
|
||||||
|
t.Fatalf("writeMod: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), "private = true") {
|
||||||
|
t.Errorf("expected `private = true` line in plugin.mod, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteMod_PrivateFalseOmitted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "plugin.mod")
|
||||||
|
m := &core.ModFile{Plugin: core.ModPlugin{
|
||||||
|
Name: "publicthing",
|
||||||
|
Scope: "themes",
|
||||||
|
Version: "0.1.0",
|
||||||
|
}}
|
||||||
|
if err := writeMod(path, m); err != nil {
|
||||||
|
t.Fatalf("writeMod: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(got), "private") {
|
||||||
|
t.Errorf("expected no `private` line, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePrivateCoord(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{in: "myplugin", want: "myplugin"},
|
||||||
|
{in: "@private/myplugin", want: "myplugin"},
|
||||||
|
{in: " myplugin ", want: "myplugin"},
|
||||||
|
{in: "@themes/myplugin", wantErr: true},
|
||||||
|
{in: "@private", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := parsePrivateCoord(c.in)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("parsePrivateCoord(%q) = %q, want error", c.in, got)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("parsePrivateCoord(%q) err: %v", c.in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parsePrivateCoord(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t",
|
||||||
|
"GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t",
|
||||||
|
"GIT_COMMITTER_EMAIL=t@t",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
cmd/ninja/cmd/root.go
Normal file
20
cmd/ninja/cmd/root.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewRoot() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "ninja",
|
||||||
|
Short: "BlockNinja developer CLI",
|
||||||
|
Long: "ninja is the developer-facing CLI for BlockNinja. First subcommand group: plugin.",
|
||||||
|
}
|
||||||
|
root.PersistentFlags().String("host", "", "Orchestrator base URL (default: from credentials or https://my.blockninjacms.com)")
|
||||||
|
root.AddCommand(newVersionCmd())
|
||||||
|
root.AddCommand(newLoginCmd())
|
||||||
|
root.AddCommand(newLogoutCmd())
|
||||||
|
root.AddCommand(newWhoamiCmd())
|
||||||
|
root.AddCommand(newPluginCmd())
|
||||||
|
root.AddCommand(newScopeCmd())
|
||||||
|
root.AddCommand(newAccountCmd())
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
19
cmd/ninja/cmd/version.go
Normal file
19
cmd/ninja/cmd/version.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
func newVersionCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print ninja version",
|
||||||
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
|
fmt.Println("ninja", Version)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
56
cmd/ninja/internal/archive/archive.go
Normal file
56
cmd/ninja/internal/archive/archive.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildSourceArchive captures the working tree as `tar.zst` bytes.
|
||||||
|
//
|
||||||
|
// When the working tree is clean it archives HEAD. When it's dirty
|
||||||
|
// (modified or staged tracked files), it archives a temporary stash
|
||||||
|
// object so the dirty state is what ships — callers that want
|
||||||
|
// HEAD-only behaviour should reject dirty trees before calling.
|
||||||
|
// Untracked files are never included regardless of state.
|
||||||
|
func BuildSourceArchive(repoDir string) ([]byte, error) {
|
||||||
|
stashCmd := exec.Command("git", "stash", "create")
|
||||||
|
stashCmd.Dir = repoDir
|
||||||
|
var stashOut, stashErr bytes.Buffer
|
||||||
|
stashCmd.Stdout = &stashOut
|
||||||
|
stashCmd.Stderr = &stashErr
|
||||||
|
if err := stashCmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("git stash create: %v: %s", err, stashErr.String())
|
||||||
|
}
|
||||||
|
treeish := "HEAD"
|
||||||
|
if sha := strings.TrimSpace(stashOut.String()); sha != "" {
|
||||||
|
treeish = sha
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("git", "archive", "--format=tar", treeish)
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
var tarOut, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &tarOut
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("git archive: %v: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var compressed bytes.Buffer
|
||||||
|
enc, err := zstd.NewWriter(&compressed, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(enc, &tarOut); err != nil {
|
||||||
|
_ = enc.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return compressed.Bytes(), nil
|
||||||
|
}
|
||||||
208
cmd/ninja/internal/archive/archive_test.go
Normal file
208
cmd/ninja/internal/archive/archive_test.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildSourceArchive_RoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
run := func(name string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t",
|
||||||
|
"GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t",
|
||||||
|
"GIT_COMMITTER_EMAIL=t@t",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run("git", "init", "-q")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "plugin.mod"),
|
||||||
|
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "ignored.log"), []byte("nope"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.log\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run("git", "add", "plugin.mod", ".gitignore")
|
||||||
|
run("git", "commit", "-qm", "init")
|
||||||
|
|
||||||
|
zstdBytes, err := BuildSourceArchive(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSourceArchive: %v", err)
|
||||||
|
}
|
||||||
|
if len(zstdBytes) == 0 {
|
||||||
|
t.Fatal("empty archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dec.Close()
|
||||||
|
tr := tar.NewReader(dec)
|
||||||
|
got := map[string]string{}
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[hdr.Name] = string(buf)
|
||||||
|
}
|
||||||
|
if _, ok := got["plugin.mod"]; !ok {
|
||||||
|
t.Errorf("expected plugin.mod in archive, got %v", keys(got))
|
||||||
|
}
|
||||||
|
if _, ok := got["ignored.log"]; ok {
|
||||||
|
t.Errorf("ignored.log should not be in archive (gitignored + untracked)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSourceArchive_DirtyTreeShipsWorkingCopy(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGitArchive(t, dir, "init", "-q")
|
||||||
|
modPath := filepath.Join(dir, "plugin.mod")
|
||||||
|
if err := os.WriteFile(modPath,
|
||||||
|
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGitArchive(t, dir, "add", "plugin.mod")
|
||||||
|
runGitArchive(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
dirtyContents := []byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n")
|
||||||
|
if err := os.WriteFile(modPath, dirtyContents, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zstdBytes, err := BuildSourceArchive(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSourceArchive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readArchive(t, zstdBytes)
|
||||||
|
contents, ok := got["plugin.mod"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected plugin.mod in archive, got %v", keys(got))
|
||||||
|
}
|
||||||
|
if !strings.Contains(contents, `version="0.1.1"`) {
|
||||||
|
t.Errorf("archived plugin.mod should have dirty version 0.1.1, got: %q", contents)
|
||||||
|
}
|
||||||
|
if strings.Contains(contents, `version="0.1.0"`) {
|
||||||
|
t.Errorf("archived plugin.mod should NOT have HEAD version 0.1.0, got: %q", contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working tree should be unchanged after stash-create.
|
||||||
|
postContents, err := os.ReadFile(modPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(postContents) != string(dirtyContents) {
|
||||||
|
t.Errorf("working tree mutated after BuildSourceArchive\nwant: %q\ngot: %q",
|
||||||
|
string(dirtyContents), string(postContents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSourceArchive_DirtyTreeOmitsUntracked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
runGitArchive(t, dir, "init", "-q")
|
||||||
|
modPath := filepath.Join(dir, "plugin.mod")
|
||||||
|
if err := os.WriteFile(modPath,
|
||||||
|
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.0\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runGitArchive(t, dir, "add", "plugin.mod")
|
||||||
|
runGitArchive(t, dir, "commit", "-qm", "init")
|
||||||
|
|
||||||
|
// Dirty the tracked file.
|
||||||
|
if err := os.WriteFile(modPath,
|
||||||
|
[]byte("[plugin]\nname=\"x\"\nscope=\"@s\"\nversion=\"0.1.1\"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Add an untracked file (no git add).
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("not tracked"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zstdBytes, err := BuildSourceArchive(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSourceArchive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readArchive(t, zstdBytes)
|
||||||
|
if _, ok := got["extra.txt"]; ok {
|
||||||
|
t.Errorf("untracked extra.txt should not be in archive, got %v", keys(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitArchive(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GIT_AUTHOR_NAME=t",
|
||||||
|
"GIT_AUTHOR_EMAIL=t@t",
|
||||||
|
"GIT_COMMITTER_NAME=t",
|
||||||
|
"GIT_COMMITTER_EMAIL=t@t",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readArchive(t *testing.T, zstdBytes []byte) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
dec, err := zstd.NewReader(bytes.NewReader(zstdBytes))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dec.Close()
|
||||||
|
tr := tar.NewReader(dec)
|
||||||
|
got := map[string]string{}
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[hdr.Name] = string(buf)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
88
cmd/ninja/internal/creds/creds.go
Normal file
88
cmd/ninja/internal/creds/creds.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package creds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
DefaultHost string `json:"default_host"`
|
||||||
|
Hosts map[string]HostCreds `json:"hosts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostCreds struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
DefaultScope string `json:"default_scope,omitempty"`
|
||||||
|
// ActiveAccountID is the orchestrator-side UUID of the account that
|
||||||
|
// account-scoped commands (notably `ninja plugins publish --private`)
|
||||||
|
// operate against. Set during `ninja login` (forced selection when the
|
||||||
|
// user belongs to more than one account) and changeable via
|
||||||
|
// `ninja account set`.
|
||||||
|
ActiveAccountID string `json:"active_account_id,omitempty"`
|
||||||
|
// ActiveAccountSlug mirrors ActiveAccountID in human-readable form for
|
||||||
|
// display in CLI output. The orchestrator-side slug is authoritative;
|
||||||
|
// the CLI refreshes it whenever it talks to the server.
|
||||||
|
ActiveAccountSlug string `json:"active_account_slug,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func filePath() (string, error) {
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "ninja", "credentials.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Credentials, error) {
|
||||||
|
p, err := filePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return &Credentials{Hosts: map[string]HostCreds{}}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &Credentials{Hosts: map[string]HostCreds{}}
|
||||||
|
if err := json.Unmarshal(b, c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credentials) Save() error {
|
||||||
|
p, err := filePath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(c, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(p, b, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Credentials) Resolve(host string) (string, HostCreds, error) {
|
||||||
|
if host == "" {
|
||||||
|
host = c.DefaultHost
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
host = "https://my.blockninjacms.com"
|
||||||
|
}
|
||||||
|
if t := os.Getenv("NINJA_TOKEN"); t != "" {
|
||||||
|
return host, HostCreds{Token: t}, nil
|
||||||
|
}
|
||||||
|
hc, ok := c.Hosts[host]
|
||||||
|
if !ok || hc.Token == "" {
|
||||||
|
return host, HostCreds{}, errors.New("not logged in; run `ninja login`")
|
||||||
|
}
|
||||||
|
return host, hc, nil
|
||||||
|
}
|
||||||
45
cmd/ninja/internal/creds/creds_test.go
Normal file
45
cmd/ninja/internal/creds/creds_test.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package creds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHostCreds_ActiveAccountRoundTrip(t *testing.T) {
|
||||||
|
src := HostCreds{
|
||||||
|
Token: "tok",
|
||||||
|
ActiveAccountID: "acct-uuid",
|
||||||
|
ActiveAccountSlug: "acme",
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal: %v", err)
|
||||||
|
}
|
||||||
|
var got HostCreds
|
||||||
|
if err := json.Unmarshal(b, &got); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if got.ActiveAccountID != "acct-uuid" {
|
||||||
|
t.Errorf("ActiveAccountID = %q, want acct-uuid", got.ActiveAccountID)
|
||||||
|
}
|
||||||
|
if got.ActiveAccountSlug != "acme" {
|
||||||
|
t.Errorf("ActiveAccountSlug = %q, want acme", got.ActiveAccountSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostCreds_LegacyFileLoadsWithoutAccount(t *testing.T) {
|
||||||
|
// A creds.json from before the active-account fields existed must still
|
||||||
|
// unmarshal cleanly; the new fields should be zero-valued.
|
||||||
|
legacy := `{"token":"tok","user":"alice","default_scope":"@themes"}`
|
||||||
|
var got HostCreds
|
||||||
|
if err := json.Unmarshal([]byte(legacy), &got); err != nil {
|
||||||
|
t.Fatalf("Unmarshal legacy: %v", err)
|
||||||
|
}
|
||||||
|
if got.Token != "tok" {
|
||||||
|
t.Errorf("Token = %q", got.Token)
|
||||||
|
}
|
||||||
|
if got.ActiveAccountID != "" || got.ActiveAccountSlug != "" {
|
||||||
|
t.Errorf("ActiveAccount* should be empty for legacy file, got id=%q slug=%q",
|
||||||
|
got.ActiveAccountID, got.ActiveAccountSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
cmd/ninja/internal/orchclient/client.go
Normal file
42
cmd/ninja/internal/orchclient/client.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package orchclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"git.dev.alexdunmow.com/block/core/internal/api/orchestrator/v1/orchestratorv1connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Host string
|
||||||
|
Token string
|
||||||
|
Auth orchestratorv1connect.PluginAuthServiceClient
|
||||||
|
Scope orchestratorv1connect.PluginScopeServiceClient
|
||||||
|
Reg orchestratorv1connect.PluginRegistryServiceClient
|
||||||
|
Pub orchestratorv1connect.PluginPublishServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host, token string) *Client {
|
||||||
|
httpClient := &http.Client{}
|
||||||
|
opts := []connect.ClientOption{
|
||||||
|
connect.WithInterceptors(bearerInterceptor(token)),
|
||||||
|
}
|
||||||
|
c := &Client{Host: host, Token: token}
|
||||||
|
c.Auth = orchestratorv1connect.NewPluginAuthServiceClient(httpClient, host, opts...)
|
||||||
|
c.Scope = orchestratorv1connect.NewPluginScopeServiceClient(httpClient, host, opts...)
|
||||||
|
c.Reg = orchestratorv1connect.NewPluginRegistryServiceClient(httpClient, host, opts...)
|
||||||
|
c.Pub = orchestratorv1connect.NewPluginPublishServiceClient(httpClient, host, opts...)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerInterceptor(token string) connect.Interceptor {
|
||||||
|
return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
|
||||||
|
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
|
||||||
|
if token != "" {
|
||||||
|
req.Header().Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
return next(ctx, req)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
15
cmd/ninja/main.go
Normal file
15
cmd/ninja/main.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/cmd/ninja/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := cmd.NewRoot().Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,13 +35,13 @@ type PostInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Content provides content access for plugins.
|
// Content provides content access for plugins.
|
||||||
// The CMS implements this interface and wires it into ServiceDeps.
|
// The CMS implements this interface and wires it into CoreServices.
|
||||||
type Content interface {
|
type Content interface {
|
||||||
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
||||||
GetPage(ctx context.Context, slug string) (*PageInfo, error)
|
GetPage(ctx context.Context, slug string) (*PageInfo, error)
|
||||||
GetPost(ctx context.Context, slug string) (*PostInfo, error)
|
GetPost(ctx context.Context, slug string) (*PostInfo, error)
|
||||||
Slugify(text string) string
|
Slugify(text string) string
|
||||||
BlockNoteToHTML(doc map[string]any) string
|
BlockNoteToHTML(ctx context.Context, doc map[string]any) string
|
||||||
GenerateExcerpt(html string, maxLen int) string
|
GenerateExcerpt(html string, maxLen int) string
|
||||||
StripHTML(s string) string
|
StripHTML(s string) string
|
||||||
}
|
}
|
||||||
|
|||||||
21
datasources/datasources.go
Normal file
21
datasources/datasources.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package datasources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Datasources provides bucket/datasource access for plugins.
|
||||||
|
// The CMS implements this interface and wires it into CoreServices.
|
||||||
|
type Datasources interface {
|
||||||
|
ResolveBucket(ctx context.Context, bucketID uuid.UUID) (*Result, error)
|
||||||
|
ResolveBucketByKey(ctx context.Context, bucketKey string) (*Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the output from resolving a bucket.
|
||||||
|
type Result struct {
|
||||||
|
Items []any `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Meta map[string]any `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
@ -0,0 +1,305 @@
|
|||||||
|
# UAT — Plugin publish (tarball) + categories
|
||||||
|
|
||||||
|
Date: 2026-06-03
|
||||||
|
|
||||||
|
> **THIS CHECKLIST IS A HARD GATE.** Implementation is NOT complete until
|
||||||
|
> every item below has been executed and observed to pass. Build-green and
|
||||||
|
> tests-green are necessary but NOT sufficient. The work is unfinished if
|
||||||
|
> any UAT item is skipped, glossed over, or marked done without the exact
|
||||||
|
> observation recorded.
|
||||||
|
>
|
||||||
|
> Do not edit this file to soften any item. If an item turns out to be
|
||||||
|
> ambiguous or impossible, STOP and ask the user to amend it. Do not
|
||||||
|
> rationalise around it.
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
Execute each item against a freshly-rebuilt orchestrator (`podman compose
|
||||||
|
build orchestrator-backend && podman compose up -d orchestrator-backend`)
|
||||||
|
and a freshly-reinstalled CLI (`go install ./cmd/ninja` from `~/src/blockninja/core`).
|
||||||
|
For each item, capture the actual output/observation next to the
|
||||||
|
expectation. Mark a checkbox ONLY after observing the success criterion
|
||||||
|
with your own eyes.
|
||||||
|
|
||||||
|
The DB connection used in items below is whatever the local orchestrator
|
||||||
|
uses; commands assume access via `podman exec blockninja-db psql -U orchestrator orchestrator`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group A — `ninja plugin init`
|
||||||
|
|
||||||
|
### A1. Init writes plugin.mod with all fields and auto-commits it
|
||||||
|
|
||||||
|
- [x] Setup:
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/uat-a1 && mkdir /tmp/uat-a1 && cd /tmp/uat-a1
|
||||||
|
git init -q && git commit --allow-empty -qm "initial"
|
||||||
|
```
|
||||||
|
- [x] Run: `ninja plugin init --host https://my.localdev.blockninjacms.com --scope @themes --name uat-a1` (answer: kind=plugin, categories=1,2)
|
||||||
|
- [x] Observe `cat plugin.mod` includes:
|
||||||
|
- `name = "uat-a1"`
|
||||||
|
- `scope = "@themes"`
|
||||||
|
- `version = "0.1.0"`
|
||||||
|
- `kind = "plugin"`
|
||||||
|
- `categories = ["analytics", "seo"]`
|
||||||
|
- [x] Observe `git log --oneline` shows a commit whose subject is `Add plugin.mod` at HEAD.
|
||||||
|
- [x] Observe `git remote -v` outputs nothing (no `ninja` remote).
|
||||||
|
- [x] Observe `grep -A1 '\[remote' .git/config` finds NO `ninja` block.
|
||||||
|
|
||||||
|
### A2. Init for a theme writes kind=theme and skips category prompt
|
||||||
|
|
||||||
|
- [x] Setup:
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/uat-a2 && mkdir /tmp/uat-a2 && cd /tmp/uat-a2
|
||||||
|
git init -q && git commit --allow-empty -qm "initial"
|
||||||
|
```
|
||||||
|
- [x] Run init for `@themes/uat-a2`, answer kind=theme. The category prompt MUST NOT appear.
|
||||||
|
- [x] Observe `plugin.mod` contains `kind = "theme"` and NO `categories =` line.
|
||||||
|
|
||||||
|
### A3. Init when not in a git repo prints a warning, still registers
|
||||||
|
|
||||||
|
- [x] Setup:
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/uat-a3 && mkdir /tmp/uat-a3 && cd /tmp/uat-a3
|
||||||
|
```
|
||||||
|
- [x] Run init for `@themes/uat-a3`. STDOUT/STDERR contains a warning mentioning `git init` before publish.
|
||||||
|
- [x] `plugin.mod` exists on disk.
|
||||||
|
- [x] No `.git` directory was created.
|
||||||
|
- [x] Database row exists: `psql -c "SELECT 1 FROM registry_plugins WHERE name='uat-a3';"` returns 1.
|
||||||
|
|
||||||
|
### A4. Init twice with same name returns AlreadyExists, leaves repo unchanged
|
||||||
|
|
||||||
|
- [x] In a freshly-init'd repo with no plugin.mod, run init twice with the same scope+name.
|
||||||
|
- [x] Second invocation errors with a message mentioning the plugin already exists.
|
||||||
|
- [x] `git log` shows exactly ONE `Add plugin.mod` commit (not two).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group B — `ninja plugin publish`
|
||||||
|
|
||||||
|
### B1. Publish posts a tar.zst, no git tag, no git push
|
||||||
|
|
||||||
|
- [x] Setup from a working init'd repo (e.g. uat-a1 above).
|
||||||
|
- [x] Pre-record:
|
||||||
|
```bash
|
||||||
|
git tag --list # capture before
|
||||||
|
git rev-parse HEAD # capture HEAD before
|
||||||
|
```
|
||||||
|
- [x] Run `ninja plugin publish --host https://my.localdev.blockninjacms.com`.
|
||||||
|
- [x] STDOUT contains `Published @themes/uat-a1@0.1.0 (NNN bytes)`.
|
||||||
|
- [x] After publish, `git tag --list` output is byte-identical to before. (NO `v0.1.0` tag was created.)
|
||||||
|
- [x] `git rev-parse HEAD` is unchanged.
|
||||||
|
- [x] `git remote -v` is still empty.
|
||||||
|
- [x] DB row exists with the new version:
|
||||||
|
```sql
|
||||||
|
SELECT v.version, v.source_archive_key, v.source_archive_size, v.source_archive_sha256
|
||||||
|
FROM registry_versions v
|
||||||
|
JOIN registry_plugins p ON p.id = v.plugin_id
|
||||||
|
WHERE p.name = 'uat-a1';
|
||||||
|
```
|
||||||
|
Observe: `source_archive_key` ends with `/source.tar.zst`. `source_archive_size > 0`.
|
||||||
|
|
||||||
|
### B2. The stored archive matches the posted bytes (sha256)
|
||||||
|
|
||||||
|
- [x] On disk (or via the signed URL), retrieve the stored archive bytes.
|
||||||
|
- [x] Compute `sha256sum <archive>`. Compare against `source_archive_sha256` from the DB.
|
||||||
|
- [x] Hashes match exactly.
|
||||||
|
|
||||||
|
### B3. The archive contains only tracked files and excludes gitignored
|
||||||
|
|
||||||
|
- [x] Setup:
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/uat-b3 && mkdir /tmp/uat-b3 && cd /tmp/uat-b3
|
||||||
|
git init -q
|
||||||
|
echo "secret" > ignored.log
|
||||||
|
echo "ignored.log" > .gitignore
|
||||||
|
git add .gitignore && git commit -qm "ignore"
|
||||||
|
```
|
||||||
|
- [x] Run init + publish for `@themes/uat-b3`.
|
||||||
|
- [x] Extract the stored archive (download via signed URL → `zstd -d | tar -xv`).
|
||||||
|
- [x] Observe `plugin.mod`, `.gitignore` present; `ignored.log` absent.
|
||||||
|
|
||||||
|
### B4. Working tree dirty check fires; `--allow-dirty` bypasses
|
||||||
|
|
||||||
|
- [x] In a published repo, touch a file: `echo x >> README.md`.
|
||||||
|
- [x] Run `ninja plugin publish ...` — must error with text including `working tree dirty`.
|
||||||
|
- [x] Bump patch, then `ninja plugin publish --allow-dirty ...` — must succeed.
|
||||||
|
|
||||||
|
### B5. Tracked-yet-gitignored files trigger a warning
|
||||||
|
|
||||||
|
- [x] Setup:
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/uat-b5 && mkdir /tmp/uat-b5 && cd /tmp/uat-b5
|
||||||
|
git init -q
|
||||||
|
echo "data" > dist.log
|
||||||
|
git add dist.log && git commit -qm "tracked"
|
||||||
|
echo "dist.log" > .gitignore
|
||||||
|
git add .gitignore && git commit -qm "ignore"
|
||||||
|
```
|
||||||
|
- [x] Init + publish for `@themes/uat-b5`.
|
||||||
|
- [x] STDERR includes a warning that `dist.log` is tracked and will still be shipped, plus the hint `git rm --cached`.
|
||||||
|
|
||||||
|
### B6. Duplicate version rejected
|
||||||
|
|
||||||
|
- [x] In a successfully-published repo, immediately re-run publish without bumping.
|
||||||
|
- [x] Error includes `version already published`.
|
||||||
|
|
||||||
|
### B7. Version mismatch between plugin.mod and request rejected
|
||||||
|
|
||||||
|
- [x] Edit `plugin.mod` to a different version than the one expected by the bump sequence (manually write `version = "9.9.9"` without committing).
|
||||||
|
- [x] Run publish — must succeed locally but the orchestrator may accept or reject; if rejected the error mentions version mismatch. Either way, no DB row at version `9.9.9` should exist unless it succeeded — record what happened.
|
||||||
|
- [x] Reset:
|
||||||
|
```bash
|
||||||
|
git checkout plugin.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group C — Categories + kind validation
|
||||||
|
|
||||||
|
### C1. ListCategories returns the seeded set
|
||||||
|
|
||||||
|
- [x] Run:
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListCategories \
|
||||||
|
-H 'Content-Type: application/json' -d '{}'
|
||||||
|
```
|
||||||
|
- [x] Response includes ALL of: `analytics`, `seo`, `social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
|
||||||
|
|
||||||
|
### C2. CreatePlugin rejects unknown category
|
||||||
|
|
||||||
|
- [x] Setup a fresh repo and start init manually entering a bogus category number — NOT possible via the CLI menu. Instead, hit the RPC directly:
|
||||||
|
```bash
|
||||||
|
TOKEN=$(jq -r '.hosts["https://my.localdev.blockninjacms.com"].token' ~/.config/ninja/creds.json)
|
||||||
|
curl -sS -X POST \
|
||||||
|
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"scopeSlug":"themes","name":"uat-c2","kind":"plugin","categories":["bogus"]}'
|
||||||
|
```
|
||||||
|
- [x] Response is a Connect error with code `invalid_argument` and message mentioning unknown category.
|
||||||
|
|
||||||
|
### C3. CreatePlugin rejects theme with categories
|
||||||
|
|
||||||
|
- [x] Same setup as C2 but kind=theme with categories=["analytics"]:
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/CreatePlugin \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"scopeSlug":"themes","name":"uat-c3","kind":"theme","categories":["analytics"]}'
|
||||||
|
```
|
||||||
|
- [x] Response is invalid_argument with message including "themes do not carry categories".
|
||||||
|
|
||||||
|
### C4. CreatePlugin rejects unknown kind
|
||||||
|
|
||||||
|
- [x] POST with `kind: "module"` (or anything other than plugin/theme).
|
||||||
|
- [x] Response is invalid_argument.
|
||||||
|
|
||||||
|
### C5. PublishVersion rejects plugin.mod kind mismatch
|
||||||
|
|
||||||
|
- [x] In a published-once `plugin`-kind repo, hand-edit `plugin.mod` to `kind = "theme"`, commit, bump, publish.
|
||||||
|
- [x] Publish errors with text including `kind does not match registered`.
|
||||||
|
|
||||||
|
### C6. PublishVersion rejects plugin.mod categories mismatch
|
||||||
|
|
||||||
|
- [x] In a published-once `plugin`-kind repo, add a category to `plugin.mod` that wasn't in the original `CreatePlugin`, commit, bump, publish.
|
||||||
|
- [x] Publish errors with text including `categories do not match registered`.
|
||||||
|
|
||||||
|
### C7. ListPlugins filters by kind
|
||||||
|
|
||||||
|
- [x] Setup: at least one `plugin` and one `theme` in the registry (any from earlier UAT items).
|
||||||
|
- [x] Hit ListPlugins with `kind=plugin`:
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ListPlugins \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"kind":"plugin"}'
|
||||||
|
```
|
||||||
|
Response contains only kind=plugin rows.
|
||||||
|
- [x] Same with `{"kind":"theme"}`. Response contains only kind=theme rows.
|
||||||
|
- [x] With `{}` (no filter). Response contains both.
|
||||||
|
|
||||||
|
### C8. ListPlugins filters by category
|
||||||
|
|
||||||
|
- [x] Setup a plugin with categories=["analytics"].
|
||||||
|
- [x] Hit ListPlugins with `{"categories":["analytics"]}`. The plugin appears.
|
||||||
|
- [x] Hit with `{"categories":["forms"]}`. The plugin does NOT appear.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group D — Deletion of git infrastructure
|
||||||
|
|
||||||
|
### D1. `/git/*` HTTP route is gone
|
||||||
|
|
||||||
|
- [x] Run:
|
||||||
|
```bash
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" \
|
||||||
|
https://my.localdev.blockninjacms.com/git/themes/lcars.git/info/refs?service=git-upload-pack
|
||||||
|
```
|
||||||
|
- [x] HTTP code is `404` (not 200, not 403). The route does not exist.
|
||||||
|
|
||||||
|
### D2. `registry/git/` package is fully removed
|
||||||
|
|
||||||
|
- [x] Run:
|
||||||
|
```bash
|
||||||
|
ls ~/src/blockninja/orchestrator/backend/internal/registry/ 2>&1
|
||||||
|
```
|
||||||
|
- [x] Output does NOT include a `git` directory.
|
||||||
|
- [x] `grep -r "internal/registry/git" ~/src/blockninja/orchestrator/backend/ 2>/dev/null | grep -v "^Binary"` returns nothing.
|
||||||
|
|
||||||
|
### D3. `RegistryGitPath` config is gone
|
||||||
|
|
||||||
|
- [x] Run `grep -n "RegistryGitPath\|REGISTRY_GIT_PATH" ~/src/blockninja/orchestrator/backend/internal/config/config.go`.
|
||||||
|
- [x] Output is empty.
|
||||||
|
|
||||||
|
### D4. `registry_versions.git_commit` / `git_tag` columns are gone
|
||||||
|
|
||||||
|
- [x] Run:
|
||||||
|
```bash
|
||||||
|
podman exec blockninja-db psql -U orchestrator orchestrator -c "\d registry_versions"
|
||||||
|
```
|
||||||
|
- [x] Output shows NO column named `git_commit` and NO column named `git_tag`.
|
||||||
|
|
||||||
|
### D5. `Plugin.git_remote_url` is not in the proto
|
||||||
|
|
||||||
|
- [x] Run `grep -n "git_remote_url\|GitRemoteUrl" ~/src/blockninja/orchestrator/proto/orchestrator/v1/plugin_registry.proto ~/src/blockninja/core/proto/orchestrator/v1/plugin_registry.proto`.
|
||||||
|
- [x] No matches.
|
||||||
|
|
||||||
|
### D6. The CLI no longer references the `ninja` git remote
|
||||||
|
|
||||||
|
- [x] Run `grep -nE 'remote\s+(add|remove)\s+"?ninja' ~/src/blockninja/core/cmd/ninja/cmd/plugin.go`.
|
||||||
|
- [x] No matches.
|
||||||
|
- [x] Run `grep -nE '"git",\s*"push",\s*"ninja"' ~/src/blockninja/core/cmd/ninja/cmd/plugin.go`.
|
||||||
|
- [x] No matches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group E — Smoke install path
|
||||||
|
|
||||||
|
### E1. ResolveInstall returns the new .tar.zst URL
|
||||||
|
|
||||||
|
- [x] For any published plugin/version (e.g. uat-a1@0.1.0), call ResolveInstall:
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
https://my.localdev.blockninjacms.com/orchestrator.v1.PluginRegistryService/ResolveInstall \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"scopeSlug":"themes","pluginName":"uat-a1","versionOrChannel":"0.1.0"}'
|
||||||
|
```
|
||||||
|
- [x] Response contains `archiveUrl` whose path ends with `/source.tar.zst` and includes `sig=` and `exp=` query params.
|
||||||
|
|
||||||
|
### E2. The signed URL actually downloads
|
||||||
|
|
||||||
|
- [x] `curl -sS -o /tmp/uat-e2.tar.zst "<archiveUrl from E1>"`
|
||||||
|
- [x] `file /tmp/uat-e2.tar.zst` reports a Zstandard compressed file.
|
||||||
|
- [x] `sha256sum /tmp/uat-e2.tar.zst` matches the `archiveSha256` from the ResolveInstall response.
|
||||||
|
- [x] `zstd -dc /tmp/uat-e2.tar.zst | tar -tf - | head` shows `plugin.mod` and the expected files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sign-off
|
||||||
|
|
||||||
|
- [x] Every box above is ticked, with at-the-time-of-execution observation logged inline.
|
||||||
|
- [x] Any unticked box → work is NOT complete. Return to the plan.
|
||||||
|
- [x] Final test reports any deviations from expected behaviour even when the box can be ticked.
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
# Execution prompt — Plugin publish (tarball) + categories
|
||||||
|
|
||||||
|
Copy the body below verbatim into a new Claude Code session at the repo
|
||||||
|
root (`~/src/blockninja/core`) when you want execution to start. The prompt is
|
||||||
|
self-contained: anyone reading it should be able to drive the work end
|
||||||
|
to end without further context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Begin prompt
|
||||||
|
|
||||||
|
You are the inline execution agent for the "Plugin publish (tarball) +
|
||||||
|
categories" change. You are doing the work; you are not advising. Follow
|
||||||
|
the process below in order. Do not skip steps. Do not improvise.
|
||||||
|
|
||||||
|
### 1. Required reading (in this order)
|
||||||
|
|
||||||
|
Read these three files fully before touching any code. Treat each as
|
||||||
|
authoritative for its purpose. They live in `~/src/blockninja/core`:
|
||||||
|
|
||||||
|
1. **Spec / design** — `docs/superpowers/specs/2026-06-03-plugin-publish-and-categories-design.md`. Defines *what* and *why*. Do not change this without the user's explicit OK.
|
||||||
|
2. **Plan** — `docs/superpowers/plans/2026-06-03-plugin-publish-and-categories.md`. Defines the ordered, bite-sized tasks with full code blocks. Do not change this without the user's explicit OK either. Each task ends with a commit.
|
||||||
|
3. **UAT checklist** — `docs/superpowers/audits/2026-06-03-plugin-publish-and-categories-uat.md`. The hard gate. The work is NOT done until every UAT item has been observed to pass with your own eyes. The UAT may not be softened, skipped, or interpreted away. If any item turns out to be ambiguous or impossible, STOP and ask the user.
|
||||||
|
|
||||||
|
Also be aware: work spans TWO git repos.
|
||||||
|
- `~/src/blockninja/core` — Go module `git.dev.alexdunmow.com/block/core`. Holds the ninja CLI (`cmd/ninja/`), the SDK (`plugin/`), and a mirrored copy of the orchestrator proto under `proto/` + `internal/api/`.
|
||||||
|
- `~/src/blockninja/orchestrator` — Go module at `backend/`. Holds the orchestrator service, schema, migrations, and proto source of truth.
|
||||||
|
|
||||||
|
CLAUDE.md in each repo applies. Read `~/src/blockninja/core/CLAUDE.md` if you have not (key rule: no `replace` directives in any `go.mod`).
|
||||||
|
|
||||||
|
### 2. Process
|
||||||
|
|
||||||
|
Invoke the `superpowers:executing-plans` skill and follow its discipline.
|
||||||
|
Within that:
|
||||||
|
|
||||||
|
- Work tasks in the order written in the plan (Task 1 → Task 20 + Task 11.5). Do not reorder. The order encodes dependency.
|
||||||
|
- After completing each task, commit per the commit step in the task. Do not batch multiple tasks into one commit. Do not amend the user's previous commits.
|
||||||
|
- After each task's commit, run an internal checkpoint: did the build pass? did the tests added in this task pass? did this task actually do what the plan said? If anything is off, fix it before moving on.
|
||||||
|
- Stop and report at any of these points:
|
||||||
|
- Task instructions are unclear or contradicted by current code.
|
||||||
|
- A task's code block doesn't compile after applying it verbatim AND the fix isn't obvious from the build error.
|
||||||
|
- `make sqlc` produces field names that differ from what later tasks reference (this is plausible; sqlc field naming sometimes surprises).
|
||||||
|
- You need to install tooling that isn't already on the system (e.g. `buf`, `goose`, `sqlc`).
|
||||||
|
- Do NOT edit the plan or design to fit reality. If reality has diverged, surface that to the user and ask how to resolve.
|
||||||
|
|
||||||
|
### 3. When to dispatch subagents
|
||||||
|
|
||||||
|
The plan is mostly sequential. Most tasks should be executed inline by
|
||||||
|
you. Dispatch a `general-purpose` subagent ONLY for the tasks listed
|
||||||
|
below. These are isolated, pure-function, well-bounded units whose work
|
||||||
|
benefits from a clean context. Hand the subagent the full task body
|
||||||
|
including all code blocks; tell it the working directory; tell it the
|
||||||
|
exact files to create or modify; tell it not to commit (you commit after
|
||||||
|
reviewing the changes).
|
||||||
|
|
||||||
|
Subagent-suitable tasks:
|
||||||
|
|
||||||
|
- **Task 3** — `[core] BuildSourceArchive`. Pure helper in a new package. Self-contained TDD.
|
||||||
|
- **Task 4** — `[orch] OpenAndValidate`. Pure helper, TDD, no external state.
|
||||||
|
- **Task 5** — `[orch] Drop git fields migration + sqlc regen`. Mechanical schema + regen. Pure file manipulation.
|
||||||
|
- **Task 14** — `[orch] kind + categories migration + sqlc regen`. Same shape as Task 5.
|
||||||
|
|
||||||
|
All other tasks (proto changes, handler rewrites, wiring, CLI changes,
|
||||||
|
delete-the-git-package) you do yourself in the main context. Reasons:
|
||||||
|
they cross many files, depend on context from earlier tasks, and require
|
||||||
|
running and reading the orchestrator/CLI in your hands.
|
||||||
|
|
||||||
|
Smoke tests (Tasks 12, 20) and UAT execution: do these yourself in the
|
||||||
|
main context. Subagents cannot easily run interactive flows or report
|
||||||
|
back observations of running services.
|
||||||
|
|
||||||
|
### 4. UAT gate (HARD)
|
||||||
|
|
||||||
|
After Task 20 (and Task 11.5) are committed and pushed-ready in both
|
||||||
|
repos, execute the UAT checklist
|
||||||
|
(`docs/superpowers/audits/2026-06-03-plugin-publish-and-categories-uat.md`)
|
||||||
|
top to bottom. For each box:
|
||||||
|
|
||||||
|
- Run the command(s) the item specifies, against the actual orchestrator
|
||||||
|
(you may need to `podman compose build orchestrator-backend &&
|
||||||
|
podman compose up -d orchestrator-backend` first) and the actual
|
||||||
|
installed CLI (`cd ~/src/blockninja/core && go install ./cmd/ninja`).
|
||||||
|
- Observe the result with your own eyes.
|
||||||
|
- Tick the box ONLY if the observation matches the expectation.
|
||||||
|
- If it doesn't match: do not tick. Return to the plan, identify which
|
||||||
|
task is implicated, and either fix it (new commit) or report back.
|
||||||
|
|
||||||
|
You may not declare the work complete with any UAT box unticked. You
|
||||||
|
may not pre-tick boxes "based on the test suite passing". UAT is the
|
||||||
|
gate; tests are not.
|
||||||
|
|
||||||
|
If an UAT item is ambiguous, ask the user before guessing.
|
||||||
|
|
||||||
|
### 5. Tone
|
||||||
|
|
||||||
|
Short status updates between tasks. State what's next in one line. State
|
||||||
|
problems concretely with the command and output that demonstrated the
|
||||||
|
problem. No speculation, no apologies. When asking the user, present
|
||||||
|
specific options.
|
||||||
|
|
||||||
|
### 6. Done
|
||||||
|
|
||||||
|
When all UAT items are ticked, summarise in one message:
|
||||||
|
- Total commits made across both repos.
|
||||||
|
- Any deviations from the plan (with task numbers and one-line justification).
|
||||||
|
- Status of each UAT group (A–E) — all pass.
|
||||||
|
- Suggested next step (likely: announce the change in the team channel, or move on to a follow-up like an admin UI for category management — both out of scope here).
|
||||||
|
|
||||||
|
## End prompt
|
||||||
2350
docs/superpowers/plans/2026-06-03-plugin-publish-and-categories.md
Normal file
2350
docs/superpowers/plans/2026-06-03-plugin-publish-and-categories.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,267 @@
|
|||||||
|
# Plugin publish (tarball) + categories — design
|
||||||
|
|
||||||
|
Date: 2026-06-03
|
||||||
|
Status: Draft
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Two related changes to the plugin registry:
|
||||||
|
|
||||||
|
1. **Drop git as the publish transport.** The current flow has `ninja plugin
|
||||||
|
publish` push a git tag to a bare repo served over smart-HTTP by the
|
||||||
|
orchestrator. This couples the client to git state (the `ninja` remote
|
||||||
|
stored in `.git/config` just bit us with a URL-shape drift bug), forces
|
||||||
|
the orchestrator to maintain bare repos plus an archive store, and gives
|
||||||
|
the developer a remote they didn't ask for. Move to a single artifact:
|
||||||
|
the CLI builds a `tar.zst` from the source tree and posts it.
|
||||||
|
|
||||||
|
2. **First-class plugin kind, configurable category list.** Today
|
||||||
|
`registry_plugins.categories` (`text[]`) and `Plugin.categories` (proto)
|
||||||
|
exist but nothing writes to them, there is no canonical list, and themes
|
||||||
|
are not distinguished from plugins. Add a `kind` field
|
||||||
|
(`plugin` | `theme`), validate categories against a server-side canonical
|
||||||
|
list, and surface both in the CLI.
|
||||||
|
|
||||||
|
Greenfield — no consumers depend on the existing model. We can change shapes
|
||||||
|
and wipe existing rows.
|
||||||
|
|
||||||
|
## Plugin kind & categories
|
||||||
|
|
||||||
|
### Model
|
||||||
|
|
||||||
|
`registry_plugins` gains:
|
||||||
|
- `kind text NOT NULL DEFAULT 'plugin' CHECK (kind IN ('plugin', 'theme'))`
|
||||||
|
|
||||||
|
`registry_categories` is a new table:
|
||||||
|
- `slug text PRIMARY KEY` — e.g. `analytics`, `social`, `import-export`
|
||||||
|
- `display_name text NOT NULL`
|
||||||
|
- `description text NOT NULL DEFAULT ''`
|
||||||
|
- `sort_order int NOT NULL DEFAULT 0`
|
||||||
|
|
||||||
|
Seeded at migration time with a starter list (configurable later via an
|
||||||
|
admin endpoint; out of scope for this spec). `registry_plugins.categories`
|
||||||
|
values must be subsets of `registry_categories.slug`. Validation is
|
||||||
|
enforced by the API layer, not by FK — categories may be removed later
|
||||||
|
without orphaning plugins.
|
||||||
|
|
||||||
|
Themes do not carry categories in v1. Categories on theme rows are rejected
|
||||||
|
with `CodeInvalidArgument`.
|
||||||
|
|
||||||
|
### plugin.mod
|
||||||
|
|
||||||
|
`[plugin]` gains two fields:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugin]
|
||||||
|
name = "lcars"
|
||||||
|
scope = "@themes"
|
||||||
|
version = "0.2.0"
|
||||||
|
kind = "theme" # plugin | theme — defaults to "plugin" if absent
|
||||||
|
categories = ["analytics"] # only allowed when kind = "plugin"
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI keeps `plugin.mod` in sync with the registry. On `init` it writes
|
||||||
|
both fields. On `publish` it sends them through and the orchestrator
|
||||||
|
verifies the tarball's `plugin.mod` matches the request.
|
||||||
|
|
||||||
|
### Proto
|
||||||
|
|
||||||
|
`Plugin` gains:
|
||||||
|
- `string kind = 10;`
|
||||||
|
|
||||||
|
`Plugin.categories` (existing) stays. `CreatePluginRequest` gains `kind`
|
||||||
|
and `categories`. Listing endpoints gain a `kind` filter:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message ListPluginsRequest {
|
||||||
|
int32 limit = 1;
|
||||||
|
int32 offset = 2;
|
||||||
|
string query = 3;
|
||||||
|
string kind = 4; // "" = any, "plugin", "theme"
|
||||||
|
repeated string categories = 5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A new `ListCategoriesRequest/Response` exposes the canonical list to the
|
||||||
|
CLI for interactive picking.
|
||||||
|
|
||||||
|
## Tarball publish flow
|
||||||
|
|
||||||
|
### Format
|
||||||
|
|
||||||
|
`tar.zst` — `archive/tar` from stdlib for tarring, `github.com/klauspost/compress/zstd`
|
||||||
|
for compression. Same dep on both ends. Default zstd level 3 (fast,
|
||||||
|
~gzip-9 ratio).
|
||||||
|
|
||||||
|
A hard size cap of 25 MiB on the *compressed* (posted) bytes, enforced
|
||||||
|
both sides. Configurable via `REGISTRY_PUBLISH_MAX_BYTES`. The orchestrator
|
||||||
|
additionally caps decompressed size at 4× the configured value to limit
|
||||||
|
zip-bomb exposure during validation.
|
||||||
|
|
||||||
|
### Archive contents
|
||||||
|
|
||||||
|
`git archive --format=tar HEAD` piped through zstd. This gives:
|
||||||
|
- Only tracked files. Anything matching `.gitignore` is excluded so long
|
||||||
|
as it isn't already tracked — i.e. `.gitignore` is honoured by virtue of
|
||||||
|
those files never having been added. The one edge case is a file that
|
||||||
|
was committed *before* being added to `.gitignore`: `git archive` will
|
||||||
|
still ship it. We do not paper over this — the developer must
|
||||||
|
`git rm --cached <file>` to drop it from the index. Documented in the
|
||||||
|
CLI publish output if we detect tracked files matching the current
|
||||||
|
`.gitignore`.
|
||||||
|
- A clean snapshot at HEAD.
|
||||||
|
- Deterministic enough that re-running publish with no changes produces
|
||||||
|
byte-identical archives modulo timestamps.
|
||||||
|
|
||||||
|
The CLI shells out to `git archive HEAD` (no in-process git library
|
||||||
|
dependency). It does not produce a tag, push, or modify `.git/config`.
|
||||||
|
|
||||||
|
### Proto change
|
||||||
|
|
||||||
|
`PublishVersionRequest` becomes:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message PublishVersionRequest {
|
||||||
|
string plugin_id = 1;
|
||||||
|
string version = 2; // was: git_ref; now plain "0.2.0"
|
||||||
|
string channel = 3;
|
||||||
|
bytes archive = 4; // tar.zst, max 25 MiB
|
||||||
|
string readme_md = 5;
|
||||||
|
string changelog_md = 6;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`git_ref` is removed.
|
||||||
|
|
||||||
|
### Server-side validation
|
||||||
|
|
||||||
|
The orchestrator:
|
||||||
|
1. Verifies the caller is a scope member.
|
||||||
|
2. Decompresses the archive in-memory, walks the tar, finds `plugin.mod`.
|
||||||
|
3. Parses `plugin.mod` and checks `scope`, `name`, `version`, `kind`, and
|
||||||
|
`categories` match the request and the plugin row.
|
||||||
|
4. Re-validates categories against `registry_categories.slug`.
|
||||||
|
5. Computes sha256 and size of the *compressed* archive bytes (what gets
|
||||||
|
stored).
|
||||||
|
6. Stores via `regstorage.Put` with key
|
||||||
|
`<scope>/<name>/<version>/source.tar.zst` and content-type
|
||||||
|
`application/zstd`.
|
||||||
|
7. Records the `registry_versions` row.
|
||||||
|
8. Returns the existing `PublishVersionResponse` shape (a signed
|
||||||
|
`archive_url` for immediate download verification).
|
||||||
|
|
||||||
|
`git_commit` / `git_tag` columns on `registry_versions` are dropped.
|
||||||
|
|
||||||
|
### Install flow
|
||||||
|
|
||||||
|
`ResolveInstall` still returns a signed URL. The only change is the object
|
||||||
|
extension (`.tar.zst` instead of `.tar.gz`) and content-type.
|
||||||
|
|
||||||
|
## Init flow
|
||||||
|
|
||||||
|
`ninja plugin init`:
|
||||||
|
|
||||||
|
1. Prompt scope (existing behavior).
|
||||||
|
2. Prompt plugin name (existing).
|
||||||
|
3. Prompt kind (`plugin` | `theme`). Default: `plugin`.
|
||||||
|
4. If `kind == plugin`: fetch the canonical category list via
|
||||||
|
`ListCategories` and let the user multi-select (zero or more).
|
||||||
|
5. Write `plugin.mod` with all fields.
|
||||||
|
6. Call `CreatePlugin` with kind + categories.
|
||||||
|
7. If in a git repo and `plugin.mod` is new or modified: `git add plugin.mod`,
|
||||||
|
`git commit -m "Add plugin.mod"`. If the file is unchanged from HEAD,
|
||||||
|
skip the commit.
|
||||||
|
8. **Does not** add a `ninja` git remote. Does not touch `.git/config`.
|
||||||
|
|
||||||
|
If not in a git repo, init still writes `plugin.mod` and registers the
|
||||||
|
plugin; it prints a warning that the user will need to `git init` before
|
||||||
|
they can `publish` (`git archive` requires a repo).
|
||||||
|
|
||||||
|
## Publish flow
|
||||||
|
|
||||||
|
`ninja plugin publish`:
|
||||||
|
|
||||||
|
1. Read `plugin.mod`.
|
||||||
|
2. Auto-commit `plugin.mod` if it's dirty (path-scoped commit, so any other
|
||||||
|
staged paths are left alone). Backstop for the bump-then-publish flow
|
||||||
|
when `bump --no-commit` was used or the version was hand-edited.
|
||||||
|
3. Dirty-tree handling:
|
||||||
|
- Default: any remaining dirty paths ship via `git stash create` →
|
||||||
|
`git archive`. An untracked-files warning fires (those don't make it
|
||||||
|
into the archive).
|
||||||
|
- `--strict`: fail with a "working tree dirty (--strict)" error if any
|
||||||
|
dirty paths remain after the plugin.mod auto-commit.
|
||||||
|
4. Lookup `Plugin` via `GetPlugin(scope, name)` to verify it exists and to
|
||||||
|
get its `id`.
|
||||||
|
5. `git archive --format=tar <treeish>` → zstd compress → bytes in memory
|
||||||
|
(treeish is `HEAD` for a clean tree, the stash-create sha for a dirty
|
||||||
|
one).
|
||||||
|
6. Optional README/CHANGELOG bytes.
|
||||||
|
7. Call `PublishVersion(plugin_id, version, channel, archive, readme,
|
||||||
|
changelog)`.
|
||||||
|
8. Print version + warnings.
|
||||||
|
|
||||||
|
`ninja plugin bump` auto-commits its `plugin.mod` change with subject
|
||||||
|
`bump to X.Y.Z`. Pass `--no-commit` to skip the commit (publish's auto-commit
|
||||||
|
backstop will pick it up on the next publish).
|
||||||
|
|
||||||
|
No tag is created. No git push. No `.git/config` writes anywhere in the
|
||||||
|
flow.
|
||||||
|
|
||||||
|
## What gets deleted
|
||||||
|
|
||||||
|
Orchestrator:
|
||||||
|
- `backend/internal/registry/git/` — the entire package (server, repo
|
||||||
|
manager, smart-HTTP, Provision, ResolveTag, ReadFileAtCommit, tests).
|
||||||
|
- `r.Route("/git", …)` in `registry_wiring.go`.
|
||||||
|
- `RegistryGitPath` config.
|
||||||
|
- `git_commit`, `git_tag` columns on `registry_versions`.
|
||||||
|
- `git_remote_url` field from `CreatePluginResponse` (the URL no longer
|
||||||
|
exists — there is nothing for the developer to push to manually).
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
- `git remote remove ninja` / `git remote add ninja …` block in
|
||||||
|
`cmd/ninja/cmd/plugin.go`.
|
||||||
|
- `git tag -a vX.Y.Z` / `git push ninja vX.Y.Z` block.
|
||||||
|
|
||||||
|
Database:
|
||||||
|
- `git_commit`, `git_tag` columns dropped.
|
||||||
|
- `registry_plugins` rows can be truncated for dev environments since the
|
||||||
|
schema is changing; production is greenfield so this is moot.
|
||||||
|
|
||||||
|
## What gets added
|
||||||
|
|
||||||
|
Orchestrator:
|
||||||
|
- `registry_categories` table + a migration that seeds it with a starter
|
||||||
|
list. Slugs only; copy can be refined later.
|
||||||
|
- A `kind` column on `registry_plugins`.
|
||||||
|
- `ListCategories` RPC added to the existing `PluginRegistryService`
|
||||||
|
(one service is fine at this size).
|
||||||
|
- Archive validation helper (open zstd, walk tar, extract `plugin.mod`).
|
||||||
|
- `REGISTRY_PUBLISH_MAX_BYTES` config (default `25 * 1024 * 1024`).
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
- `bump`, `version` (just shipped, no change).
|
||||||
|
- Init prompts for kind + categories; calls `ListCategories`.
|
||||||
|
- Publish builds `tar.zst` from `git archive HEAD`.
|
||||||
|
|
||||||
|
Proto:
|
||||||
|
- `Plugin.kind`, `CreatePluginRequest.kind/categories`,
|
||||||
|
`ListPluginsRequest.kind/categories`, new `ListCategories*` messages,
|
||||||
|
`PublishVersionRequest.archive` (bytes), removal of `git_ref` and the
|
||||||
|
top-level `git_remote_url` from `CreatePluginResponse`.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- What is the starter category list? Seed something reasonable; admins
|
||||||
|
can curate it later. Suggested starting set: `analytics`, `seo`,
|
||||||
|
`social`, `commerce`, `forms`, `import-export`, `media`, `developer`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Admin UI for managing categories (use a migration for now).
|
||||||
|
- Category translations / i18n.
|
||||||
|
- Plugin search by category in the install UI (data is there once the
|
||||||
|
field is populated; consumers can use it when ready).
|
||||||
|
- Auth for the publish endpoint — already in place via existing bearer
|
||||||
|
token interceptor.
|
||||||
340
docs/superpowers/specs/2026-06-07-private-plugins-design.md
Normal file
340
docs/superpowers/specs/2026-06-07-private-plugins-design.md
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
# Private plugins — design
|
||||||
|
|
||||||
|
Date: 2026-06-07
|
||||||
|
Status: Draft (core + orchestrator backend landed; CMS UI + publisher dashboard TODO)
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
`ninja plugin publish` currently lands every plugin in the shared public
|
||||||
|
registry under a developer-chosen scope (`@themes/foo`, `@myorg/bar`). There
|
||||||
|
is no way to publish a plugin that is restricted to a single account — useful
|
||||||
|
for in-house themes, internal blocks, or per-customer customisations that
|
||||||
|
should never appear in public listings.
|
||||||
|
|
||||||
|
We want a "private plugin" lane that:
|
||||||
|
|
||||||
|
1. The developer opts into with `ninja plugin publish --private`.
|
||||||
|
2. Is scoped to the publisher's **active account** (not a global namespace) so
|
||||||
|
anyone in that account can install it, and no one outside can see it.
|
||||||
|
3. Has CLI ergonomics, a publisher dashboard ("manage what I've published"),
|
||||||
|
and an "easy installer" in the CMS admin so site operators can browse and
|
||||||
|
install their account's private plugins in two clicks.
|
||||||
|
|
||||||
|
Greenfield — the registry currently has no live private plugins. We can change
|
||||||
|
shapes freely and the only existing rows are public.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
These were locked in during brainstorming, in order of how foundational they
|
||||||
|
are:
|
||||||
|
|
||||||
|
1. **Scope slug = sentinel `@private`.** Every private plugin lives under one
|
||||||
|
global `@private` namespace. Identity is `(owner_account_id, name)`, not
|
||||||
|
the scope slug, so two accounts can each have an `@private/myplugin`
|
||||||
|
without colliding. Coords are only meaningful inside an account.
|
||||||
|
2. **Account membership is the access boundary.** Any user in
|
||||||
|
`account_users WHERE account_id = plugin.owner_account_id` can publish,
|
||||||
|
install, list, and delete that account's privates. No roles, no per-site
|
||||||
|
grants in v1.
|
||||||
|
3. **`scope =` in `plugin.mod` is ignored when `private = true`.** Leave the
|
||||||
|
value alone in the file, emit a warning at publish.
|
||||||
|
4. **`private = true` lives in `plugin.mod`.** First `--private` rewrites the
|
||||||
|
mod file; subsequent `ninja plugin publish` (no flag) continues to publish
|
||||||
|
private. Re-publishing without `private = true` against an existing private
|
||||||
|
plugin is blocked in v1 (no graduation).
|
||||||
|
5. **Active account, gcloud-style.** After `ninja login`, the server returns
|
||||||
|
the user's accounts. The CLI forces a selection (auto-picks if only one),
|
||||||
|
persists it in `creds.json`, and `ninja account set <slug>` changes it
|
||||||
|
later.
|
||||||
|
6. **CMS installer = a new "Private" tab on `/admin/plugins`** listing the
|
||||||
|
plugins owned by the CMS site's account.
|
||||||
|
7. **Publisher dashboard = my.blockninjacms.com** (account portal), not the
|
||||||
|
CMS admin. Lists plugins + versions, shows "installed on N sites" with a
|
||||||
|
drilldown, supports deleting a version or the whole plugin (delete-plugin
|
||||||
|
is disabled while any site has it installed).
|
||||||
|
8. **CLI parity for delete.** `ninja plugin delete @private/<name>` and
|
||||||
|
`ninja plugin delete-version <name> --version=<v>`.
|
||||||
|
9. **Same channel model** as public (`--channel=latest|beta|...`).
|
||||||
|
10. **No tier gating, no quotas, no graduation in v1.**
|
||||||
|
11. **`ninja plugin init --private`** skips the scope prompt and writes
|
||||||
|
`private = true`.
|
||||||
|
12. **`ninja plugin list`** shows a `Public` section then a
|
||||||
|
`Private — account: <name>` section.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Four layers, four deliverables. Two are landed, two are still TODO.
|
||||||
|
|
||||||
|
| Layer | Repo | Status | Deliverable |
|
||||||
|
|------------------|-------------------------------------|----------|----------------------------------------------------------|
|
||||||
|
| Shared proto | `block/proto` | ✅ | `plugin_registry.proto` carries the union of all surfaces |
|
||||||
|
| Registry core | `block/core` + `orchestrator/backend` | ✅ | Migration, RPC handlers, auth, sqlc |
|
||||||
|
| Developer CLI | `core/cmd/ninja` | ✅ | `--private`, `account` subcommand, list/delete |
|
||||||
|
| CMS site admin | `cms/web` (`/admin/plugins`) | ⏳ TODO | New "Private" tab + backend route |
|
||||||
|
| Publisher portal | `my.blockninjacms.com` | ⏳ TODO | `/private-plugins` dashboard |
|
||||||
|
|
||||||
|
Data flow for the two operations that exist today:
|
||||||
|
|
||||||
|
```
|
||||||
|
publish --private
|
||||||
|
ninja --(gRPC, bearer user token)--> orchestrator.PluginRegistryService.CreatePlugin
|
||||||
|
{ scope_slug:"private", visibility:PRIVATE,
|
||||||
|
active_account_id }
|
||||||
|
authz: requireAccountMembership
|
||||||
|
row: registry_plugins
|
||||||
|
scope_id = @private sentinel
|
||||||
|
owner_account_id = X
|
||||||
|
ninja --(gRPC)--> orchestrator.PluginPublishService.PublishVersion(plugin_id, …)
|
||||||
|
|
||||||
|
CMS install (Private tab) (TODO)
|
||||||
|
cms/web --(cms API)--> cms/backend
|
||||||
|
cms/backend --(X-CMS-Secret, instance_id)--> orchestrator.PluginRegistryService
|
||||||
|
.ResolveInstall(scope_slug="private",
|
||||||
|
plugin_name,
|
||||||
|
version_or_channel,
|
||||||
|
active_account_id)
|
||||||
|
authz: requireAccountMembership
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-repo proto consolidation (incidental but load-bearing)
|
||||||
|
|
||||||
|
Building this revealed that `core` and `orchestrator` were independently
|
||||||
|
forking `plugin_registry.proto` and drifting. As part of this work both repos
|
||||||
|
now consume the canonical `block/proto` repo as a git submodule:
|
||||||
|
|
||||||
|
- `core/proto/` is now a submodule of `block/proto`.
|
||||||
|
- `core` only generates `plugin_registry.proto` via
|
||||||
|
`buf generate --path proto/orchestrator/v1/plugin_registry.proto`. Generating
|
||||||
|
the rest of `orchestrator/v1` (accounts, instances, …) would register proto
|
||||||
|
descriptors that the orchestrator's own bindings also register, and panic at
|
||||||
|
startup with "file ... is already registered".
|
||||||
|
- `core/plugin/mod.go` no longer imports the generated proto package. The
|
||||||
|
`VisibilityLabel` helper that previously lived there was the load-bearing
|
||||||
|
leak (every consumer of `core/plugin` transitively pulled in core's
|
||||||
|
bindings). It now lives in `core/cmd/ninja/cmd/plugin.go` as `visibilityLabel`.
|
||||||
|
- The CLI's `ListMyAccountsForCLI` RPC + `MyAccount` message intentionally
|
||||||
|
duplicate `AccountService.ListMyAccounts` in `accounts.proto`. They look
|
||||||
|
like a duplicate but they're not: this one stays in `plugin_registry.proto`
|
||||||
|
so core can resolve account membership without needing `accounts.proto`
|
||||||
|
generated. They carry the minimum the CLI cares about (`id`, `slug`, `name`).
|
||||||
|
Naming is `*ForCLI` to avoid the message-name collision with `AccountService`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Migration `backend/sql/migrations/00059_private_plugins.sql`:
|
||||||
|
|
||||||
|
1. `ALTER TABLE registry_scopes ALTER COLUMN owner_user_id DROP NOT NULL` so
|
||||||
|
the `@private` sentinel scope can exist without a user owner.
|
||||||
|
2. Seed the sentinel scope row:
|
||||||
|
`INSERT INTO registry_scopes (id, slug, display_name, owner_user_id)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000001', 'private', 'Private', NULL)`.
|
||||||
|
Fixed UUID lets handlers reference it without a lookup.
|
||||||
|
3. `ALTER TABLE registry_plugins ADD COLUMN owner_account_id UUID REFERENCES accounts(id) ON DELETE RESTRICT`.
|
||||||
|
NULL for public plugins.
|
||||||
|
4. Drop the table-wide `UNIQUE (scope_id, name)`. Replace with two partial
|
||||||
|
uniques: `(scope_id, name) WHERE owner_account_id IS NULL` for publics, and
|
||||||
|
`(owner_account_id, name) WHERE owner_account_id IS NOT NULL` for privates.
|
||||||
|
This is what lets two different accounts each have an `@private/myplugin`.
|
||||||
|
5. CHECK constraint `registry_plugins_visibility_consistency`: a row is either
|
||||||
|
public-scope-owned or account-owned, never both.
|
||||||
|
|
||||||
|
Knock-on effect: `OwnerUserID` in sqlc-generated params is now
|
||||||
|
`uuid.NullUUID`. The scope-create code wraps `claims.UserID` accordingly.
|
||||||
|
|
||||||
|
## Proto
|
||||||
|
|
||||||
|
In `proto/orchestrator/v1/plugin_registry.proto` (in the shared repo, all
|
||||||
|
changes landed except the last one which is still local — see Open issues):
|
||||||
|
|
||||||
|
- `enum PluginVisibility` — `UNSPECIFIED`, `PRIVATE`, `UNDER_REVIEW`, `PUBLIC`,
|
||||||
|
`REJECTED`, `TAKEN_DOWN`.
|
||||||
|
- `Plugin.visibility` converted from `string` to `PluginVisibility` (breaking
|
||||||
|
wire change; greenfield so we accepted it).
|
||||||
|
- `Plugin.owner_account_id` (`string`, field 12) — empty for publics.
|
||||||
|
- `CreatePluginRequest` gains `PluginVisibility visibility` and
|
||||||
|
`string active_account_id`.
|
||||||
|
- `GetPluginRequest` gains `string active_account_id` (resolved when
|
||||||
|
`scope_slug = "private"`).
|
||||||
|
- `ResolveInstallRequest` gains `string active_account_id` (same).
|
||||||
|
- New on `PluginRegistryService`:
|
||||||
|
- `ListPrivatePlugins(account_id) -> [PrivatePluginSummary]`
|
||||||
|
- `DeletePrivatePlugin(account_id, plugin_name)`
|
||||||
|
- `DeletePrivatePluginVersion(account_id, plugin_name, version)`
|
||||||
|
- `ListPrivatePluginInstallSites(account_id, plugin_name) -> [PrivatePluginInstallSite]`
|
||||||
|
- New on `PluginAuthService`:
|
||||||
|
- `ListMyAccountsForCLI() -> [MyAccount]` (see the proto-consolidation
|
||||||
|
section above for why this is intentionally not in `accounts.proto`).
|
||||||
|
- New message `MyAccount { id, slug, name }`.
|
||||||
|
- New messages `PrivatePluginSummary { plugin, channel_versions, installed_site_count }`
|
||||||
|
and `PrivatePluginInstallSite { instance_id, site_display_name, installed_version, pinned_channel }`.
|
||||||
|
|
||||||
|
Also folded into the shared proto from the orchestrator's old local-only fork:
|
||||||
|
`PluginScopeService.ListMyPlugins`, `PluginRegistryService.SubmitForReview`,
|
||||||
|
and the full `PluginModerationService` (`ListPendingReviews`,
|
||||||
|
`ApproveSubmission`, `RejectSubmission`, `RequestChanges`).
|
||||||
|
|
||||||
|
## ninja CLI (`core/cmd/ninja`)
|
||||||
|
|
||||||
|
- **`cmd/account.go`** (new) — `ninja account list / set <slug> / show`.
|
||||||
|
- **`cmd/login.go`** — after device-flow approval, calls `ListMyAccountsForCLI`:
|
||||||
|
0 errors, 1 auto-selects, ≥2 prompts via `pickAccountInteractive`. Persists
|
||||||
|
to `creds.json`.
|
||||||
|
- **`internal/creds/creds.go`** — `HostCreds` gains `ActiveAccountID` and
|
||||||
|
`ActiveAccountSlug`. Legacy files load unchanged (`creds_test.go` covers
|
||||||
|
both round-trip and legacy).
|
||||||
|
- **`plugin/mod.go`** — `ModPlugin.Private bool` field; `Coords()` returns
|
||||||
|
`@private/<name>@<version>` when `Private=true` regardless of `Scope`.
|
||||||
|
Exported `PrivateScopeSlug` constant. Tests in `plugin/mod_test.go`.
|
||||||
|
- **`cmd/plugin.go`**:
|
||||||
|
- `newPluginInitCmd`: `--private` flag; skips scope prompt, calls
|
||||||
|
`CreatePlugin(visibility=PRIVATE, active_account_id=…)`, writes
|
||||||
|
`private = true` to `plugin.mod`, omits the `scope` line. Inherits
|
||||||
|
`private = true` from existing mod so re-running `init` can't silently
|
||||||
|
downgrade.
|
||||||
|
- `newPluginPublishCmd`: `--private` flag is sticky — first use rewrites the
|
||||||
|
mod file. Warns when both `scope` and `private = true` are present.
|
||||||
|
Calls `GetPlugin(scope_slug="private", active_account_id=…)` then
|
||||||
|
`PublishVersion`.
|
||||||
|
- `newPluginListCmd`: sectioned output (`Public` via
|
||||||
|
`ListMyScopes`+`GetScope`, `Private — account: <slug>` via
|
||||||
|
`ListPrivatePlugins`). `--public-only` / `--private-only` filters.
|
||||||
|
- `newPluginDeleteCmd` / `newPluginDeleteVersionCmd`: `--yes` confirm.
|
||||||
|
`parsePrivateCoord` accepts either bare name or `@private/<name>` and
|
||||||
|
rejects any other scope so a slip can't delete a public plugin.
|
||||||
|
|
||||||
|
## Orchestrator backend
|
||||||
|
|
||||||
|
`backend/internal/registry/api/private_plugins.go` (new) implements:
|
||||||
|
- `requireAccountMembership(ctx, accountID)` — single authorisation
|
||||||
|
chokepoint. Returns 401 on unauthenticated, 400 on unparseable UUID, 404
|
||||||
|
(deliberately, hides existence) when caller isn't a member.
|
||||||
|
- `ListPrivatePlugins` — fans out to `ListPrivatePluginChannelVersions` and
|
||||||
|
per-plugin `CountPrivatePluginInstalledSites` to compose summaries.
|
||||||
|
- `DeletePrivatePlugin` — blocks while install count > 0
|
||||||
|
(`CountPrivatePluginInstalledSites`).
|
||||||
|
- `DeletePrivatePluginVersion`.
|
||||||
|
- `ListPrivatePluginInstallSites` — drilldown via the
|
||||||
|
`registry_install_events` ⨝ `instances` join. (Caveat: install events are
|
||||||
|
append-only and there's no uninstall event, so "installed on N sites" is
|
||||||
|
the conservative "ever installed" approximation. Documented in the SQL
|
||||||
|
query.)
|
||||||
|
|
||||||
|
`backend/internal/registry/api/plugin.go` gains branches in:
|
||||||
|
- `CreatePlugin` — `slug=="private"` or `visibility=PRIVATE` →
|
||||||
|
`CreatePrivateRegistryPlugin` under the sentinel scope.
|
||||||
|
- `GetPlugin` — `slug=="private"` → `GetPrivateRegistryPluginByAccountAndName`.
|
||||||
|
- `ResolveInstall` — same.
|
||||||
|
|
||||||
|
`backend/internal/registry/api/converters.go` — `visibilityToProto(string)`
|
||||||
|
maps the DB text column to the new `PluginVisibility` enum; unknown values
|
||||||
|
fall through to `UNSPECIFIED`.
|
||||||
|
|
||||||
|
`backend/internal/registry/auth/device.go` — `ListMyAccountsForCLI` returns
|
||||||
|
`MyAccount` rows from a new sqlc query `ListAccountsForUser`.
|
||||||
|
|
||||||
|
`backend/internal/rbac/interceptor.go` — RBAC entries added: every new private
|
||||||
|
RPC is `RoleUser` (handlers do the per-account membership check on top).
|
||||||
|
`ListMyAccountsForCLI` is also `RoleUser`.
|
||||||
|
|
||||||
|
sqlc queries added to `backend/sql/queries/registry_plugins.sql`:
|
||||||
|
- `CreatePrivateRegistryPlugin`, `GetPrivateRegistryPluginByAccountAndName`,
|
||||||
|
`ListPrivateRegistryPluginsForAccount`,
|
||||||
|
- `DeletePrivateRegistryPlugin`, `DeletePrivateRegistryPluginVersion`,
|
||||||
|
- `ListPrivatePluginChannelVersions`,
|
||||||
|
- `CountPrivatePluginInstalledSites`,
|
||||||
|
- `ListPrivatePluginInstalledSites`.
|
||||||
|
|
||||||
|
And to `backend/sql/queries/account_users.sql`:
|
||||||
|
- `ListAccountsForUser` — accounts the user is an accepted member of, with
|
||||||
|
role.
|
||||||
|
|
||||||
|
## CMS site admin UI (TODO)
|
||||||
|
|
||||||
|
`cms/web/routes/admin/plugins.tsx` — add a "Private" tab. The tab fetches via
|
||||||
|
a new `cms/backend` route that calls `orchestrator.PluginRegistryService
|
||||||
|
.ListPrivatePlugins(account_id=<site's account>)` with `X-CMS-Secret`. Same
|
||||||
|
card layout as Browse, with an Install button that calls
|
||||||
|
`installFromRegistry` with `scope_slug:"private"` and the site's
|
||||||
|
`active_account_id`.
|
||||||
|
|
||||||
|
Note: `ResolveInstall` already supports the private branch; the CMS-side
|
||||||
|
plumbing is what's missing.
|
||||||
|
|
||||||
|
## Publisher dashboard (TODO)
|
||||||
|
|
||||||
|
`my.blockninjacms.com/account/<slug>/private-plugins`. Auth: existing user
|
||||||
|
JWT; gate on `IsAccountMember`. Sections:
|
||||||
|
- List of plugins (display name, version count, "installed on N sites").
|
||||||
|
- Per-plugin drawer with versions per channel, "installed on" drilldown
|
||||||
|
(`ListPrivatePluginInstallSites`), delete-version buttons.
|
||||||
|
- Plugin-level delete button, disabled with tooltip when installs > 0.
|
||||||
|
|
||||||
|
## Out of scope for v1
|
||||||
|
|
||||||
|
- Graduating a private plugin to public (or vice versa). Hard error if
|
||||||
|
attempted.
|
||||||
|
- Per-site grants inside an account (every site in the account sees every
|
||||||
|
private plugin owned by the account).
|
||||||
|
- Subscription/tier gating; quotas. A `private_plugin_limit` column on plans
|
||||||
|
is left as a placeholder for later.
|
||||||
|
- Editing display_name / description / categories without a version bump.
|
||||||
|
- Audit log of who published / installed / deleted.
|
||||||
|
- Private plugin "homepage_url" UI.
|
||||||
|
- Server-rendered registry browse page for privates (CLI + CMS + publisher
|
||||||
|
dashboard cover all consumption surfaces).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Build/test gates (all green):
|
||||||
|
- `core`: `go build ./... && go test ./...` clean. Plugin mod and CLI tests:
|
||||||
|
`TestParseModFull_PrivateField`, `TestParseModFull_PrivateDefaultsFalse`,
|
||||||
|
`TestCoords_PrivateOverridesScope`, `TestCoords_PrivateNoScope`,
|
||||||
|
`TestWriteMod_PrivateTrueSerializes`, `TestWriteMod_PrivateFalseOmitted`,
|
||||||
|
`TestParsePrivateCoord`, `TestHostCreds_ActiveAccountRoundTrip`,
|
||||||
|
`TestHostCreds_LegacyFileLoadsWithoutAccount`.
|
||||||
|
- `orchestrator/backend`: `go build ./... && go test ./...` clean — 22/22
|
||||||
|
packages including `internal/registry/api`, `internal/registry/auth`,
|
||||||
|
`internal/server`, `internal/rbac`.
|
||||||
|
|
||||||
|
End-to-end manual flow once CMS + dashboard land:
|
||||||
|
|
||||||
|
1. **Account selection.** `ninja login` against a user with two accounts →
|
||||||
|
picker fires, choice persists. `ninja account show` confirms.
|
||||||
|
`ninja account set <slug>` switches.
|
||||||
|
2. **Publish.** `ninja plugin init --private` in a fresh dir → scope prompt
|
||||||
|
skipped, `plugin.mod` has `private = true`. `ninja plugin publish --private` →
|
||||||
|
coord `@private/myplugin@0.1.0`. Same name re-published from a different
|
||||||
|
account succeeds (unique key is `(account_id, name)`).
|
||||||
|
3. **CMS install.** Private tab shows the plugin on the publisher's account's
|
||||||
|
site; empty on a site in a different account. Direct API attempt with
|
||||||
|
another account's `X-CMS-Secret` → 403.
|
||||||
|
4. **Dashboard.** Lists plugin, shows "installed on 1 site", drilldown lists
|
||||||
|
that site + pinned version. Delete-plugin disabled until site uninstalls.
|
||||||
|
Delete-version on a non-installed version succeeds; CLI `plugins list`
|
||||||
|
reflects it.
|
||||||
|
5. **CLI parity.** `ninja plugin list` shows sections; `delete-version` and
|
||||||
|
`delete` work with `--yes`; server returns `FAILED_PRECONDITION` when
|
||||||
|
install count > 0.
|
||||||
|
|
||||||
|
## Open issues / followups
|
||||||
|
|
||||||
|
- **`ResolveInstallRequest.active_account_id` is uncommitted on the proto
|
||||||
|
side.** The shared `block/proto` working tree currently sits on
|
||||||
|
`feat/plugin-latest-version` with a separate WIP commit, plus many
|
||||||
|
`go_package` renames (`block/ninja` → `block/cms`) across the whole tree.
|
||||||
|
Once that branch lands, we cycle: push the proto change → tag `core`
|
||||||
|
v0.12.x → bump `orchestrator`'s submodule and core pin → push.
|
||||||
|
- **Submodule pointer drift.** Both `core` and `orchestrator` have the shared
|
||||||
|
proto as a submodule; pinning is independent. Distribution via
|
||||||
|
`make release` + `make distribute-sdk` in core already covers go.mod bumps;
|
||||||
|
submodule pointer updates are still manual.
|
||||||
|
- **`InstallFromRegistry` (cms/backend → orchestrator).** This is the CMS-side
|
||||||
|
entry point that delegates to `ResolveInstall`. It already exists for the
|
||||||
|
public path; needs the private branch (pass `scope_slug="private"` +
|
||||||
|
`active_account_id` from the instance's account) when the CMS Private tab
|
||||||
|
ships.
|
||||||
|
- **Install-tracking is approximate.** `registry_install_events` is append-
|
||||||
|
only, so the "N sites" count and dashboard drilldown count
|
||||||
|
ever-installed-and-still-existing sites, not currently-installed sites. A
|
||||||
|
v2 could add an `instance_plugins.registry_version_id` FK so we have a
|
||||||
|
precise current-install signal.
|
||||||
13
go.mod
13
go.mod
@ -1,18 +1,25 @@
|
|||||||
module git.dev.alexdunmow.com/block/core
|
module git.dev.alexdunmow.com/block/core
|
||||||
|
|
||||||
go 1.26
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.19.2
|
connectrpc.com/connect v1.20.0
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/a-h/templ v0.3.1001
|
github.com/a-h/templ v0.3.1001
|
||||||
|
github.com/flosch/pongo2/v6 v6.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
golang.org/x/mod v0.34.0
|
golang.org/x/mod v0.34.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
35
go.sum
35
go.sum
@ -1,14 +1,21 @@
|
|||||||
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/flosch/pongo2/v6 v6.1.0 h1:A/NJbrQJJD2B2mbpw3DRFwBYG0xpCr3vwFlEr46y1HQ=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/flosch/pongo2/v6 v6.1.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@ -17,22 +24,38 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||||
|
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
4295
internal/api/orchestrator/v1/plugin_registry.pb.go
Normal file
4295
internal/api/orchestrator/v1/plugin_registry.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,7 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.dev.alexdunmow.com/block/core/blocks"
|
"git.dev.alexdunmow.com/block/core/blocks"
|
||||||
)
|
)
|
||||||
@ -18,15 +19,25 @@ func NewPluginBlockRegistry(inner blocks.BlockRegistry, pluginName string) *Plug
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) {
|
func (r *PluginBlockRegistry) Register(meta blocks.BlockMeta, fn blocks.BlockFunc) {
|
||||||
meta.Key = r.prefix + ":" + meta.Key
|
if !strings.Contains(meta.Key, ":") {
|
||||||
|
meta.Key = r.prefix + ":" + meta.Key
|
||||||
|
}
|
||||||
meta.Source = r.prefix
|
meta.Source = r.prefix
|
||||||
r.inner.Register(meta, fn)
|
r.inner.Register(meta, fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) {
|
func (r *PluginBlockRegistry) RegisterTemplateOverride(templateKey, blockKey string, fn blocks.BlockFunc) {
|
||||||
r.inner.RegisterTemplateOverride(templateKey, blockKey, fn)
|
r.inner.RegisterTemplateOverrideWithSource(templateKey, blockKey, r.prefix, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginBlockRegistry) RegisterTemplateOverrideWithSource(templateKey, blockKey, source string, fn blocks.BlockFunc) {
|
||||||
|
r.inner.RegisterTemplateOverrideWithSource(templateKey, blockKey, source, fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
|
func (r *PluginBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
|
||||||
return r.inner.LoadSchemasFromFS(fsys)
|
return r.inner.LoadSchemasFromFSWithPrefix(fsys, r.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PluginBlockRegistry) LoadSchemasFromFSWithPrefix(fsys fs.FS, prefix string) error {
|
||||||
|
return r.inner.LoadSchemasFromFSWithPrefix(fsys, prefix)
|
||||||
}
|
}
|
||||||
|
|||||||
70
plugin/block_registry_test.go
Normal file
70
plugin/block_registry_test.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingBlockRegistry struct {
|
||||||
|
registeredKey string
|
||||||
|
registeredSource string
|
||||||
|
overrideSource string
|
||||||
|
schemaPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingBlockRegistry) Register(meta blocks.BlockMeta, _ blocks.BlockFunc) {
|
||||||
|
r.registeredKey = meta.Key
|
||||||
|
r.registeredSource = meta.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingBlockRegistry) RegisterTemplateOverride(_, _ string, _ blocks.BlockFunc) {}
|
||||||
|
|
||||||
|
func (r *recordingBlockRegistry) RegisterTemplateOverrideWithSource(_, _, source string, _ blocks.BlockFunc) {
|
||||||
|
r.overrideSource = source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingBlockRegistry) LoadSchemasFromFS(fsys fs.FS) error {
|
||||||
|
return r.LoadSchemasFromFSWithPrefix(fsys, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingBlockRegistry) LoadSchemasFromFSWithPrefix(_ fs.FS, prefix string) error {
|
||||||
|
r.schemaPrefix = prefix
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginBlockRegistryPrefixesOnlyUnqualifiedKeys(t *testing.T) {
|
||||||
|
inner := &recordingBlockRegistry{}
|
||||||
|
registry := NewPluginBlockRegistry(inner, "course")
|
||||||
|
|
||||||
|
registry.Register(blocks.BlockMeta{Key: "lesson"}, nil)
|
||||||
|
if inner.registeredKey != "course:lesson" {
|
||||||
|
t.Fatalf("Register() key = %q, want course:lesson", inner.registeredKey)
|
||||||
|
}
|
||||||
|
if inner.registeredSource != "course" {
|
||||||
|
t.Fatalf("Register() source = %q, want course", inner.registeredSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(blocks.BlockMeta{Key: "course:lesson"}, nil)
|
||||||
|
if inner.registeredKey != "course:lesson" {
|
||||||
|
t.Fatalf("Register() double-prefixed qualified key: %q", inner.registeredKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginBlockRegistryTracksOverrideAndSchemaSource(t *testing.T) {
|
||||||
|
inner := &recordingBlockRegistry{}
|
||||||
|
registry := NewPluginBlockRegistry(inner, "course")
|
||||||
|
|
||||||
|
registry.RegisterTemplateOverride("shared-theme", "lesson", nil)
|
||||||
|
if inner.overrideSource != "course" {
|
||||||
|
t.Fatalf("override source = %q, want course", inner.overrideSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := registry.LoadSchemasFromFS(nil); err != nil {
|
||||||
|
t.Fatalf("LoadSchemasFromFS() error = %v", err)
|
||||||
|
}
|
||||||
|
if inner.schemaPrefix != "course" {
|
||||||
|
t.Fatalf("schema prefix = %q, want course", inner.schemaPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"git.dev.alexdunmow.com/block/core/auth"
|
"git.dev.alexdunmow.com/block/core/auth"
|
||||||
"git.dev.alexdunmow.com/block/core/content"
|
"git.dev.alexdunmow.com/block/core/content"
|
||||||
"git.dev.alexdunmow.com/block/core/crypto"
|
"git.dev.alexdunmow.com/block/core/crypto"
|
||||||
|
"git.dev.alexdunmow.com/block/core/datasources"
|
||||||
"git.dev.alexdunmow.com/block/core/gating"
|
"git.dev.alexdunmow.com/block/core/gating"
|
||||||
"git.dev.alexdunmow.com/block/core/menus"
|
"git.dev.alexdunmow.com/block/core/menus"
|
||||||
"git.dev.alexdunmow.com/block/core/settings"
|
"git.dev.alexdunmow.com/block/core/settings"
|
||||||
@ -15,14 +16,15 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceDeps provides dependencies that plugins need for RPC service handlers.
|
// CoreServices provides CMS capabilities to plugins.
|
||||||
type ServiceDeps struct {
|
type CoreServices struct {
|
||||||
// Capability interfaces — typed access to CMS functionality
|
// Capability interfaces — typed access to CMS functionality
|
||||||
Content content.Content
|
Content content.Content
|
||||||
Settings settings.Settings
|
Settings settings.Settings
|
||||||
Gating gating.Gating
|
Gating gating.Gating
|
||||||
Crypto crypto.Crypto
|
Crypto crypto.Crypto
|
||||||
Menus menus.Menus
|
Menus menus.Menus
|
||||||
|
Datasources datasources.Datasources
|
||||||
PublicUsers auth.PublicUsers
|
PublicUsers auth.PublicUsers
|
||||||
Subscriptions subscriptions.Subscriptions
|
Subscriptions subscriptions.Subscriptions
|
||||||
|
|
||||||
|
|||||||
142
plugin/mod.go
Normal file
142
plugin/mod.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tomlpkg "github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModFile struct {
|
||||||
|
Plugin ModPlugin `toml:"plugin"`
|
||||||
|
Compatibility *ModCompat `toml:"compatibility"`
|
||||||
|
Requires []ModRequirement `toml:"requires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModPlugin struct {
|
||||||
|
// Name is the lowercase identifier used for the plugin slug in URLs and DB
|
||||||
|
// lookups. The CLI normalises this on write; the registry normalises on
|
||||||
|
// create. Use DisplayName for human-readable presentation.
|
||||||
|
Name string `toml:"name"`
|
||||||
|
// DisplayName is the human-readable form (any case). Optional; if empty
|
||||||
|
// the registry falls back to the input name with its original case.
|
||||||
|
DisplayName string `toml:"display_name,omitempty"`
|
||||||
|
// Description is the short summary surfaced in the registry. Optional.
|
||||||
|
Description string `toml:"description,omitempty"`
|
||||||
|
// Scope is the plugin owner namespace as it appears in plugin.mod. It may
|
||||||
|
// include the leading "@" (e.g. "@themes") or omit it (e.g. "themes") —
|
||||||
|
// both forms are accepted. Consumers comparing scopes should trim the "@"
|
||||||
|
// before comparing; use ModFile.Coords() for a normalised display string.
|
||||||
|
Scope string `toml:"scope"`
|
||||||
|
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
|
||||||
|
// missing packs from the bundled registry; slugs outside that whitelist are
|
||||||
|
// logged and skipped (admins install them manually). Empty / omitted means
|
||||||
|
// the plugin has no icon-pack dependencies.
|
||||||
|
RequiredIconPacks []string `toml:"required_icon_packs,omitempty"`
|
||||||
|
// Private marks the plugin as account-scoped. When true, Coords() returns
|
||||||
|
// the canonical "@private/<name>@<version>" form regardless of the Scope
|
||||||
|
// field, and the publish flow attributes the plugin to the publisher's
|
||||||
|
// active account rather than to a public scope.
|
||||||
|
Private bool `toml:"private,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModCompat struct {
|
||||||
|
BlockCore string `toml:"block_core"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModRequirement struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Version string `toml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseModFull(b []byte) (*ModFile, error) {
|
||||||
|
var m ModFile
|
||||||
|
if err := tomlpkg.Unmarshal(b, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coords returns the canonical display coordinate for the plugin in the form
|
||||||
|
// "@scope/name@version" (or "name@version" when no scope is set).
|
||||||
|
//
|
||||||
|
// The leading "@" on m.Plugin.Scope is intentionally trimmed before
|
||||||
|
// re-prefixing so that authors may write either "@themes" or "themes" in
|
||||||
|
// plugin.mod and get the same output. Callers that need the raw scope as
|
||||||
|
// written should read m.Plugin.Scope directly.
|
||||||
|
func (m *ModFile) Coords() string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if m.Plugin.Private {
|
||||||
|
return "@" + PrivateScopeSlug + "/" + m.Plugin.Name + "@" + m.Plugin.Version
|
||||||
|
}
|
||||||
|
scope := strings.TrimPrefix(m.Plugin.Scope, "@")
|
||||||
|
if scope == "" {
|
||||||
|
return m.Plugin.Name + "@" + m.Plugin.Version
|
||||||
|
}
|
||||||
|
return "@" + scope + "/" + m.Plugin.Name + "@" + m.Plugin.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateScopeSlug is the registry namespace under which all private plugins
|
||||||
|
// 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
|
||||||
|
}
|
||||||
403
plugin/mod_test.go
Normal file
403
plugin/mod_test.go
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseModFull_BasicFields(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "smartblock"
|
||||||
|
scope = "blockninja"
|
||||||
|
version = "1.4.2"
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Plugin.Name != "smartblock" {
|
||||||
|
t.Errorf("Name = %q, want smartblock", m.Plugin.Name)
|
||||||
|
}
|
||||||
|
if m.Plugin.Scope != "blockninja" {
|
||||||
|
t.Errorf("Scope = %q, want blockninja", m.Plugin.Scope)
|
||||||
|
}
|
||||||
|
if m.Plugin.Version != "1.4.2" {
|
||||||
|
t.Errorf("Version = %q, want 1.4.2", m.Plugin.Version)
|
||||||
|
}
|
||||||
|
if got := m.Coords(); got != "@blockninja/smartblock@1.4.2" {
|
||||||
|
t.Errorf("Coords() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_BackCompatNoScope(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "legacy"
|
||||||
|
version = "0.1.0"
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Plugin.Scope != "" {
|
||||||
|
t.Errorf("Scope should be empty, got %q", m.Plugin.Scope)
|
||||||
|
}
|
||||||
|
if got := m.Coords(); got != "legacy@0.1.0" {
|
||||||
|
t.Errorf("Coords() = %q, want legacy@0.1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_InvalidTOML(t *testing.T) {
|
||||||
|
_, err := ParseModFull([]byte("not valid toml = ="))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected parse error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_EmptyInput(t *testing.T) {
|
||||||
|
m, err := ParseModFull(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nil input err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Plugin.Name != "" {
|
||||||
|
t.Errorf("Name should be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_KindAndCategories(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "analyser"
|
||||||
|
scope = "blockninja"
|
||||||
|
version = "0.1.0"
|
||||||
|
kind = "plugin"
|
||||||
|
categories = ["analytics", "seo"]
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Plugin.Kind != "plugin" {
|
||||||
|
t.Errorf("Kind = %q, want plugin", m.Plugin.Kind)
|
||||||
|
}
|
||||||
|
if got := m.Plugin.Categories; len(got) != 2 || got[0] != "analytics" || got[1] != "seo" {
|
||||||
|
t.Errorf("Categories = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_KindDefaultsEmpty(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "legacy"
|
||||||
|
version = "0.1.0"
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Plugin.Kind != "" {
|
||||||
|
t.Errorf("Kind should be empty for legacy mod, got %q", m.Plugin.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoords_AcceptsScopeWithOrWithoutAt(t *testing.T) {
|
||||||
|
want := "@themes/foo@1.0.0"
|
||||||
|
|
||||||
|
withAt := &ModFile{Plugin: ModPlugin{Name: "foo", Scope: "@themes", Version: "1.0.0"}}
|
||||||
|
if got := withAt.Coords(); got != want {
|
||||||
|
t.Errorf("Coords() with leading @ = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutAt := &ModFile{Plugin: ModPlugin{Name: "foo", Scope: "themes", Version: "1.0.0"}}
|
||||||
|
if got := withoutAt.Coords(); got != want {
|
||||||
|
t.Errorf("Coords() without leading @ = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_PrivateField(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "internal-tool"
|
||||||
|
version = "0.1.0"
|
||||||
|
private = true
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if !m.Plugin.Private {
|
||||||
|
t.Errorf("Private = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_PrivateDefaultsFalse(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "public-thing"
|
||||||
|
scope = "themes"
|
||||||
|
version = "0.1.0"
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Plugin.Private {
|
||||||
|
t.Errorf("Private = true, want false (default)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoords_PrivateOverridesScope(t *testing.T) {
|
||||||
|
m := &ModFile{Plugin: ModPlugin{
|
||||||
|
Name: "myplugin",
|
||||||
|
Scope: "@themes",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Private: true,
|
||||||
|
}}
|
||||||
|
if got := m.Coords(); got != "@private/myplugin@0.1.0" {
|
||||||
|
t.Errorf("Coords() = %q, want @private/myplugin@0.1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoords_PrivateNoScope(t *testing.T) {
|
||||||
|
m := &ModFile{Plugin: ModPlugin{
|
||||||
|
Name: "myplugin",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Private: true,
|
||||||
|
}}
|
||||||
|
if got := m.Coords(); got != "@private/myplugin@0.1.0" {
|
||||||
|
t.Errorf("Coords() = %q, want @private/myplugin@0.1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_RequiredIconPacks(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "neon"
|
||||||
|
version = "0.1.0"
|
||||||
|
required_icon_packs = ["tabler", "phosphor"]
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if len(m.Plugin.RequiredIconPacks) != 2 {
|
||||||
|
t.Fatalf("RequiredIconPacks len = %d, want 2", len(m.Plugin.RequiredIconPacks))
|
||||||
|
}
|
||||||
|
if m.Plugin.RequiredIconPacks[0] != "tabler" || m.Plugin.RequiredIconPacks[1] != "phosphor" {
|
||||||
|
t.Errorf("RequiredIconPacks = %v", m.Plugin.RequiredIconPacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_RequiredIconPacksOmitted(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "noicons"
|
||||||
|
version = "0.1.0"
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if len(m.Plugin.RequiredIconPacks) != 0 {
|
||||||
|
t.Errorf("RequiredIconPacks should be empty when omitted, got %v", m.Plugin.RequiredIconPacks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequiredIconPacks(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "neon"
|
||||||
|
version = "0.1.0"
|
||||||
|
required_icon_packs = ["tabler", " phosphor ", ""]
|
||||||
|
`)
|
||||||
|
got := ParseRequiredIconPacks(src)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("ParseRequiredIconPacks len = %d (%v), want 2", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0] != "tabler" || got[1] != "phosphor" {
|
||||||
|
t.Errorf("ParseRequiredIconPacks = %v, want [tabler phosphor]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequiredIconPacks_NilOnAbsent(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "noicons"
|
||||||
|
version = "0.1.0"
|
||||||
|
`)
|
||||||
|
if got := ParseRequiredIconPacks(src); got != nil {
|
||||||
|
t.Errorf("ParseRequiredIconPacks on absent field = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequiredIconPacks_NilOnInvalidTOML(t *testing.T) {
|
||||||
|
if got := ParseRequiredIconPacks([]byte("not valid = = =")); got != nil {
|
||||||
|
t.Errorf("ParseRequiredIconPacks on invalid TOML = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseModFull_RequiresAndCompat(t *testing.T) {
|
||||||
|
src := []byte(`
|
||||||
|
[plugin]
|
||||||
|
name = "symposium"
|
||||||
|
scope = "blockninja"
|
||||||
|
version = "0.2.0"
|
||||||
|
|
||||||
|
[compatibility]
|
||||||
|
block_core = ">=1.5 <2.0"
|
||||||
|
|
||||||
|
[[requires]]
|
||||||
|
name = "@blockninja/smartblock"
|
||||||
|
version = ">=1.0 <2.0"
|
||||||
|
|
||||||
|
[[requires]]
|
||||||
|
name = "@blockninja/gotham"
|
||||||
|
version = ">=1.2"
|
||||||
|
`)
|
||||||
|
m, err := ParseModFull(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModFull err: %v", err)
|
||||||
|
}
|
||||||
|
if m.Compatibility == nil || m.Compatibility.BlockCore != ">=1.5 <2.0" {
|
||||||
|
t.Errorf("Compat = %+v", m.Compatibility)
|
||||||
|
}
|
||||||
|
if len(m.Requires) != 2 {
|
||||||
|
t.Fatalf("Requires len = %d, want 2", len(m.Requires))
|
||||||
|
}
|
||||||
|
if m.Requires[0].Name != "@blockninja/smartblock" {
|
||||||
|
t.Errorf("Requires[0].Name = %q", m.Requires[0].Name)
|
||||||
|
}
|
||||||
|
if m.Requires[1].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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,22 +27,28 @@ type PluginRegistration struct {
|
|||||||
BundledFonts func() []byte
|
BundledFonts func() []byte
|
||||||
MasterPages func() []MasterPageDefinition
|
MasterPages func() []MasterPageDefinition
|
||||||
|
|
||||||
HTTPHandler func(deps ServiceDeps) http.Handler
|
HTTPHandler func(deps CoreServices) http.Handler
|
||||||
SettingsPanel func() string
|
SettingsPanel func() string
|
||||||
AdminPages func() []AdminPage
|
AdminPages func() []AdminPage
|
||||||
|
|
||||||
CSSManifest func() *CSSManifest
|
CSSManifest func() *CSSManifest
|
||||||
ServiceHandlers func(deps ServiceDeps) (*ServiceRegistration, error)
|
ServiceHandlers func(deps CoreServices) (*ServiceRegistration, error)
|
||||||
JobHandlers func(deps ServiceDeps) map[string]JobHandlerFunc
|
JobHandlers func(deps CoreServices) map[string]JobHandlerFunc
|
||||||
AIActions func() []AIAction
|
AIActions func() []AIAction
|
||||||
|
|
||||||
DirectoryExtensions func() *DirectoryExtensions
|
DirectoryExtensions func() *DirectoryExtensions
|
||||||
MediaHooks MediaHooksProvider
|
MediaHooks MediaHooksProvider
|
||||||
|
|
||||||
Load func(deps ServiceDeps) error
|
Load func(deps CoreServices) error
|
||||||
Unload func(ctx context.Context) error
|
Unload func(ctx context.Context) error
|
||||||
|
|
||||||
Dependencies []Dependency
|
Dependencies []Dependency
|
||||||
Migrations func() fs.FS
|
Migrations func() fs.FS
|
||||||
Version string
|
Version string
|
||||||
|
|
||||||
|
// RequiredIconPacks are icon-pack slugs the CMS must ensure are installed
|
||||||
|
// before the theme loads (e.g. "tabler", "phosphor"). Loader auto-installs
|
||||||
|
// from the bundled registry; out-of-registry slugs are logged and require
|
||||||
|
// manual install.
|
||||||
|
RequiredIconPacks []string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
@ -29,6 +31,33 @@ func ParseModVersion(data []byte) string {
|
|||||||
return "0.0.0"
|
return "0.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseRequiredIconPacks extracts the required_icon_packs list from an embedded
|
||||||
|
// plugin.mod file. Returns nil if the field is absent or empty. Mirrors the
|
||||||
|
// "read straight from TOML bytes" style of ParseModVersion so plugin
|
||||||
|
// registration.go files can populate PluginRegistration.RequiredIconPacks
|
||||||
|
// without having to duplicate the slugs in Go.
|
||||||
|
func ParseRequiredIconPacks(data []byte) []string {
|
||||||
|
m, err := ParseModFull(data)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(m.Plugin.RequiredIconPacks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(m.Plugin.RequiredIconPacks))
|
||||||
|
for _, slug := range m.Plugin.RequiredIconPacks {
|
||||||
|
s := strings.TrimSpace(slug)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// CompareVersions compares two semver strings.
|
// CompareVersions compares two semver strings.
|
||||||
// Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2.
|
// Returns -1 if v1 < v2, 0 if equal, +1 if v1 > v2.
|
||||||
// Returns 0 if either version is invalid.
|
// Returns 0 if either version is invalid.
|
||||||
@ -44,3 +73,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
1
proto
Submodule
1
proto
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 9c45bfd5e1da8bd55edcce8d79cbfbd79ca065d1
|
||||||
@ -1,22 +1,26 @@
|
|||||||
package render
|
package render
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/blocks"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML.
|
// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML.
|
||||||
func BlockNoteToHTML(doc map[string]any) string {
|
func BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
|
||||||
blocks := blocksFromRaw(doc["blocks"])
|
rawBlocks := blocksFromRaw(doc["blocks"])
|
||||||
if len(blocks) == 0 {
|
if len(rawBlocks) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return renderBlocks(blocks)
|
return renderBlocks(ctx, rawBlocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderBlocks(blocks []map[string]any) string {
|
func renderBlocks(ctx context.Context, blocks []map[string]any) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
var currentListType string
|
var currentListType string
|
||||||
var listItems []map[string]any
|
var listItems []map[string]any
|
||||||
@ -34,9 +38,9 @@ func renderBlocks(blocks []map[string]any) string {
|
|||||||
fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle)
|
fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle)
|
||||||
for _, item := range listItems {
|
for _, item := range listItems {
|
||||||
content := inlineContentFromRaw(item["content"])
|
content := inlineContentFromRaw(item["content"])
|
||||||
childrenHTML := renderChildren(item["children"])
|
childrenHTML := renderChildren(ctx, item["children"])
|
||||||
sb.WriteString("<li>")
|
sb.WriteString("<li>")
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -60,7 +64,7 @@ func renderBlocks(blocks []map[string]any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushList()
|
flushList()
|
||||||
sb.WriteString(renderBlock(blockMap))
|
sb.WriteString(renderBlock(ctx, blockMap))
|
||||||
}
|
}
|
||||||
|
|
||||||
flushList()
|
flushList()
|
||||||
@ -106,18 +110,91 @@ func blocksFromRaw(raw any) []map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderBlock(block map[string]any) string {
|
func textAlignClass(props map[string]any) string {
|
||||||
|
if props == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
align, _ := props["textAlignment"].(string)
|
||||||
|
switch align {
|
||||||
|
case "left":
|
||||||
|
return "text-left"
|
||||||
|
case "center":
|
||||||
|
return "text-center"
|
||||||
|
case "right":
|
||||||
|
return "text-right"
|
||||||
|
case "justify":
|
||||||
|
return "text-justify"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeClassToken(value string) string {
|
||||||
|
for _, r := range value {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHexDigit(c rune) bool {
|
||||||
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorValueToClass(value string, prefix string) string {
|
||||||
|
if value == "" || value == "default" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if after, ok := strings.CutPrefix(value, "hex:"); ok {
|
||||||
|
hex := after
|
||||||
|
if len(hex) != 7 && len(hex) != 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i, r := range hex {
|
||||||
|
if i == 0 && r == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isHexDigit(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-[%s]", prefix, hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after, ok := strings.CutPrefix(value, "custom:"); ok {
|
||||||
|
name := safeClassToken(after)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-[hsl(var(--color-%s))]", prefix, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
value = safeClassToken(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%s", prefix, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBlock(ctx context.Context, block map[string]any) string {
|
||||||
blockType, _ := block["type"].(string)
|
blockType, _ := block["type"].(string)
|
||||||
props, _ := block["props"].(map[string]any)
|
props, _ := block["props"].(map[string]any)
|
||||||
content := inlineContentFromRaw(block["content"])
|
content := inlineContentFromRaw(block["content"])
|
||||||
childrenHTML := renderChildren(block["children"])
|
childrenHTML := renderChildren(ctx, block["children"])
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
switch blockType {
|
switch blockType {
|
||||||
case "paragraph":
|
case "paragraph":
|
||||||
sb.WriteString("<p class=\"my-4\">")
|
classNames := "my-4"
|
||||||
sb.WriteString(renderInlineContent(content))
|
if alignClass := textAlignClass(props); alignClass != "" {
|
||||||
|
classNames += " " + alignClass
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
|
||||||
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -136,28 +213,67 @@ func renderBlock(block map[string]any) string {
|
|||||||
if level > 6 {
|
if level > 6 {
|
||||||
level = 6
|
level = 6
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "<h%d class=\"mt-8 mb-4 font-bold\">", level)
|
classNames := "mt-8 mb-4 font-bold"
|
||||||
sb.WriteString(renderInlineContent(content))
|
if alignClass := textAlignClass(props); alignClass != "" {
|
||||||
|
classNames += " " + alignClass
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
|
||||||
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "</h%d>\n", level)
|
fmt.Fprintf(&sb, "</h%d>\n", level)
|
||||||
|
|
||||||
case "quote":
|
case "quote":
|
||||||
sb.WriteString("<blockquote class=\"my-4 border-l-4 border-border pl-4 text-muted-foreground\">")
|
classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground"
|
||||||
sb.WriteString(renderInlineContent(content))
|
if alignClass := textAlignClass(props); alignClass != "" {
|
||||||
|
classNames += " " + alignClass
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
|
||||||
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
sb.WriteString("</blockquote>\n")
|
sb.WriteString("</blockquote>\n")
|
||||||
|
|
||||||
|
case "checkListItem":
|
||||||
|
checked := false
|
||||||
|
if c, ok := props["checked"].(bool); ok {
|
||||||
|
checked = c
|
||||||
|
}
|
||||||
|
checkedAttr := ""
|
||||||
|
if checked {
|
||||||
|
checkedAttr = " checked"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, `<div class="check-list-item my-2 flex items-start gap-2"><input type="checkbox" disabled%s><span>`, checkedAttr)
|
||||||
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
|
sb.WriteString("</span>")
|
||||||
|
if childrenHTML != "" {
|
||||||
|
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
|
||||||
|
}
|
||||||
|
sb.WriteString("</div>\n")
|
||||||
|
|
||||||
|
case "toggleListItem":
|
||||||
|
openAttr := ""
|
||||||
|
if open, ok := props["open"].(bool); ok && open {
|
||||||
|
openAttr = " open"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
|
||||||
|
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
|
||||||
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
|
sb.WriteString("</summary>")
|
||||||
|
if childrenHTML != "" {
|
||||||
|
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
|
||||||
|
}
|
||||||
|
sb.WriteString("</details>\n")
|
||||||
|
|
||||||
case "codeBlock":
|
case "codeBlock":
|
||||||
lang := ""
|
lang := ""
|
||||||
if l, ok := props["language"].(string); ok {
|
if l, ok := props["language"].(string); ok {
|
||||||
lang = l
|
lang = l
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
|
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
sb.WriteString("</code></pre>\n")
|
sb.WriteString("</code></pre>\n")
|
||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
@ -183,6 +299,67 @@ func renderBlock(block map[string]any) string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("</figure>\n")
|
sb.WriteString("</figure>\n")
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
url := ""
|
||||||
|
caption := ""
|
||||||
|
if u, ok := props["url"].(string); ok {
|
||||||
|
url = u
|
||||||
|
}
|
||||||
|
if c, ok := props["caption"].(string); ok {
|
||||||
|
caption = c
|
||||||
|
}
|
||||||
|
sb.WriteString(`<figure class="my-6">`)
|
||||||
|
fmt.Fprintf(&sb, `<video src="%s" controls></video>`, html.EscapeString(url))
|
||||||
|
if caption != "" {
|
||||||
|
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
||||||
|
}
|
||||||
|
sb.WriteString("</figure>\n")
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
url := ""
|
||||||
|
caption := ""
|
||||||
|
if u, ok := props["url"].(string); ok {
|
||||||
|
url = u
|
||||||
|
}
|
||||||
|
if c, ok := props["caption"].(string); ok {
|
||||||
|
caption = c
|
||||||
|
}
|
||||||
|
sb.WriteString(`<figure class="my-6">`)
|
||||||
|
fmt.Fprintf(&sb, `<audio src="%s" controls></audio>`, html.EscapeString(url))
|
||||||
|
if caption != "" {
|
||||||
|
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
||||||
|
}
|
||||||
|
sb.WriteString("</figure>\n")
|
||||||
|
|
||||||
|
case "file":
|
||||||
|
url := ""
|
||||||
|
name := ""
|
||||||
|
caption := ""
|
||||||
|
if u, ok := props["url"].(string); ok {
|
||||||
|
url = u
|
||||||
|
}
|
||||||
|
if n, ok := props["name"].(string); ok {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
if c, ok := props["caption"].(string); ok {
|
||||||
|
caption = c
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = url
|
||||||
|
}
|
||||||
|
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
|
||||||
|
if url != "" {
|
||||||
|
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
|
||||||
|
sb.WriteString(html.EscapeString(name))
|
||||||
|
sb.WriteString("</a>")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(html.EscapeString(name))
|
||||||
|
}
|
||||||
|
if caption != "" {
|
||||||
|
fmt.Fprintf(&sb, `<p class="mt-2 text-sm text-muted-foreground">%s</p>`, html.EscapeString(caption))
|
||||||
|
}
|
||||||
|
sb.WriteString("</div>\n")
|
||||||
|
|
||||||
case "table":
|
case "table":
|
||||||
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
|
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
|
||||||
if tableContent, ok := block["content"].(map[string]any); ok {
|
if tableContent, ok := block["content"].(map[string]any); ok {
|
||||||
@ -198,7 +375,7 @@ func renderBlock(block map[string]any) string {
|
|||||||
cellClass = "px-4 py-3 text-left text-sm font-semibold"
|
cellClass = "px-4 py-3 text-left text-sm font-semibold"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
|
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
|
||||||
sb.WriteString(renderInlineContent(inlineContentFromRaw(cell)))
|
sb.WriteString(renderInlineContent(inlineContentFromRaw(cell), false))
|
||||||
fmt.Fprintf(&sb, "</%s>", cellTag)
|
fmt.Fprintf(&sb, "</%s>", cellTag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,6 +397,45 @@ func renderBlock(block map[string]any) string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("</table></div>\n")
|
sb.WriteString("</table></div>\n")
|
||||||
|
|
||||||
|
case "embed":
|
||||||
|
if resolver := blocks.GetEmbedResolver(ctx); resolver != nil {
|
||||||
|
blockID := ""
|
||||||
|
if v, ok := props["blockId"].(string); ok {
|
||||||
|
blockID = v
|
||||||
|
}
|
||||||
|
dataSource := ""
|
||||||
|
if v, ok := props["dataSource"].(string); ok {
|
||||||
|
dataSource = v
|
||||||
|
}
|
||||||
|
layout := "full"
|
||||||
|
if v, ok := props["layout"].(string); ok && v != "" {
|
||||||
|
layout = v
|
||||||
|
}
|
||||||
|
if blockID != "" {
|
||||||
|
if parsedID, err := uuid.Parse(blockID); err == nil {
|
||||||
|
sb.WriteString(resolver.RenderEmbed(ctx, parsedID, dataSource, layout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "statement":
|
||||||
|
sb.WriteString("<div class=\"bn-statement\">\n")
|
||||||
|
if len(content) > 0 {
|
||||||
|
sb.WriteString("<p>")
|
||||||
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
|
sb.WriteString("</p>\n")
|
||||||
|
}
|
||||||
|
if childrenHTML != "" {
|
||||||
|
sb.WriteString(childrenHTML)
|
||||||
|
}
|
||||||
|
sb.WriteString("</div>\n")
|
||||||
|
|
||||||
|
case "humanProof":
|
||||||
|
if hp := blocks.GetHumanProofBanner(ctx); hp != nil {
|
||||||
|
sb.WriteString(blocks.RenderHumanProofBanner(hp))
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
case "references":
|
case "references":
|
||||||
sb.WriteString("<div class=\"bn-references\">\n")
|
sb.WriteString("<div class=\"bn-references\">\n")
|
||||||
sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
|
sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
|
||||||
@ -246,7 +462,7 @@ func renderBlock(block map[string]any) string {
|
|||||||
default:
|
default:
|
||||||
if len(content) > 0 || childrenHTML != "" {
|
if len(content) > 0 || childrenHTML != "" {
|
||||||
sb.WriteString("<div>")
|
sb.WriteString("<div>")
|
||||||
sb.WriteString(renderInlineContent(content))
|
sb.WriteString(renderInlineContent(content, false))
|
||||||
if childrenHTML != "" {
|
if childrenHTML != "" {
|
||||||
sb.WriteString(childrenHTML)
|
sb.WriteString(childrenHTML)
|
||||||
}
|
}
|
||||||
@ -257,15 +473,15 @@ func renderBlock(block map[string]any) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderChildren(children any) string {
|
func renderChildren(ctx context.Context, children any) string {
|
||||||
blocks := blocksFromRaw(children)
|
blocks := blocksFromRaw(children)
|
||||||
if len(blocks) == 0 {
|
if len(blocks) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return renderBlocks(blocks)
|
return renderBlocks(ctx, blocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderInlineContent(content []map[string]any) string {
|
func renderInlineContent(content []map[string]any, insideLink bool) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, itemMap := range content {
|
for _, itemMap := range content {
|
||||||
itemType, _ := itemMap["type"].(string)
|
itemType, _ := itemMap["type"].(string)
|
||||||
@ -274,7 +490,18 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
|
|
||||||
switch itemType {
|
switch itemType {
|
||||||
case "text":
|
case "text":
|
||||||
rendered := html.EscapeString(text)
|
isCode := false
|
||||||
|
if styles != nil {
|
||||||
|
if c, ok := styles["code"].(bool); ok && c {
|
||||||
|
isCode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rendered string
|
||||||
|
if insideLink || isCode {
|
||||||
|
rendered = html.EscapeString(text)
|
||||||
|
} else {
|
||||||
|
rendered = autolinkText(text)
|
||||||
|
}
|
||||||
if styles != nil {
|
if styles != nil {
|
||||||
if bold, ok := styles["bold"].(bool); ok && bold {
|
if bold, ok := styles["bold"].(bool); ok && bold {
|
||||||
rendered = "<strong>" + rendered + "</strong>"
|
rendered = "<strong>" + rendered + "</strong>"
|
||||||
@ -291,9 +518,25 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
if strike, ok := styles["strikethrough"].(bool); ok && strike {
|
if strike, ok := styles["strikethrough"].(bool); ok && strike {
|
||||||
rendered = "<s>" + rendered + "</s>"
|
rendered = "<s>" + rendered + "</s>"
|
||||||
}
|
}
|
||||||
if code, ok := styles["code"].(bool); ok && code {
|
if isCode {
|
||||||
rendered = "<code>" + rendered + "</code>"
|
rendered = "<code>" + rendered + "</code>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var colorClasses []string
|
||||||
|
if textColor, ok := styles["textColor"].(string); ok && textColor != "" && textColor != "default" {
|
||||||
|
if class := colorValueToClass(textColor, "text"); class != "" {
|
||||||
|
colorClasses = append(colorClasses, class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bgColor, ok := styles["backgroundColor"].(string); ok && bgColor != "" && bgColor != "default" {
|
||||||
|
if class := colorValueToClass(bgColor, "bg"); class != "" {
|
||||||
|
colorClasses = append(colorClasses, class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(colorClasses) > 0 {
|
||||||
|
rendered = fmt.Sprintf(`<span class="%s">%s</span>`, strings.Join(colorClasses, " "), rendered)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString(rendered)
|
sb.WriteString(rendered)
|
||||||
|
|
||||||
@ -301,11 +544,11 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
href, _ := itemMap["href"].(string)
|
href, _ := itemMap["href"].(string)
|
||||||
linkContent := inlineContentFromRaw(itemMap["content"])
|
linkContent := inlineContentFromRaw(itemMap["content"])
|
||||||
if href == "" {
|
if href == "" {
|
||||||
sb.WriteString(renderInlineContent(linkContent))
|
sb.WriteString(renderInlineContent(linkContent, insideLink))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
|
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
|
||||||
sb.WriteString(renderInlineContent(linkContent))
|
sb.WriteString(renderInlineContent(linkContent, true))
|
||||||
sb.WriteString("</a>")
|
sb.WriteString("</a>")
|
||||||
|
|
||||||
case "hardBreak":
|
case "hardBreak":
|
||||||
@ -313,13 +556,92 @@ func renderInlineContent(content []map[string]any) string {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
if text != "" {
|
if text != "" {
|
||||||
sb.WriteString(html.EscapeString(text))
|
if insideLink {
|
||||||
|
sb.WriteString(html.EscapeString(text))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(autolinkText(text))
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if rawContent, ok := itemMap["content"]; ok {
|
if rawContent, ok := itemMap["content"]; ok {
|
||||||
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent)))
|
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent), insideLink))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autolinkText escapes plain text for HTML output and wraps any https:// URL
|
||||||
|
// substring in <a href="..." rel="noopener">URL</a>. Bare domains, www. and
|
||||||
|
// http:// URLs are intentionally not auto-linked.
|
||||||
|
//
|
||||||
|
// Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL.
|
||||||
|
// Closing parens, brackets and braces are kept inside the URL only when
|
||||||
|
// balanced with an opener inside the URL itself — so
|
||||||
|
// "(see https://example.com)" links only "https://example.com"
|
||||||
|
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
|
||||||
|
func autolinkText(text string) string {
|
||||||
|
const scheme = "https://"
|
||||||
|
var sb strings.Builder
|
||||||
|
rest := text
|
||||||
|
for {
|
||||||
|
idx := strings.Index(rest, scheme)
|
||||||
|
if idx < 0 {
|
||||||
|
sb.WriteString(html.EscapeString(rest))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
sb.WriteString(html.EscapeString(rest[:idx]))
|
||||||
|
|
||||||
|
// Scan forward until whitespace or a URL-terminating delimiter.
|
||||||
|
end := idx + len(scheme)
|
||||||
|
for end < len(rest) {
|
||||||
|
c := rest[end]
|
||||||
|
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '<' || c == '>' || c == '"' || c == '\'' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
// Walk back over trailing characters that should sit outside the link,
|
||||||
|
// preserving paren/bracket/brace balance.
|
||||||
|
urlEnd := end
|
||||||
|
for urlEnd > idx+len(scheme) {
|
||||||
|
last := rest[urlEnd-1]
|
||||||
|
if last == '.' || last == ',' || last == ';' || last == ':' || last == '!' || last == '?' || last == '"' || last == '\'' {
|
||||||
|
urlEnd--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := rest[idx:urlEnd]
|
||||||
|
if last == ')' && strings.Count(candidate, ")") > strings.Count(candidate, "(") {
|
||||||
|
urlEnd--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if last == ']' && strings.Count(candidate, "]") > strings.Count(candidate, "[") {
|
||||||
|
urlEnd--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if last == '}' && strings.Count(candidate, "}") > strings.Count(candidate, "{") {
|
||||||
|
urlEnd--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
url := rest[idx:urlEnd]
|
||||||
|
tail := rest[urlEnd:end]
|
||||||
|
|
||||||
|
if url == scheme {
|
||||||
|
sb.WriteString(html.EscapeString(rest[idx:end]))
|
||||||
|
} else {
|
||||||
|
escaped := html.EscapeString(url)
|
||||||
|
sb.WriteString(`<a href="`)
|
||||||
|
sb.WriteString(escaped)
|
||||||
|
sb.WriteString(`" rel="noopener">`)
|
||||||
|
sb.WriteString(escaped)
|
||||||
|
sb.WriteString(`</a>`)
|
||||||
|
if tail != "" {
|
||||||
|
sb.WriteString(html.EscapeString(tail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = rest[end:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
229
render/blocknote_autolink_test.go
Normal file
229
render/blocknote_autolink_test.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAutolinkText_PlainTextPassthrough(t *testing.T) {
|
||||||
|
got := autolinkText("just some prose with no link")
|
||||||
|
want := "just some prose with no link"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_HTMLMetacharactersEscaped(t *testing.T) {
|
||||||
|
got := autolinkText("a < b && c > d")
|
||||||
|
if !strings.Contains(got, "<") || !strings.Contains(got, ">") || !strings.Contains(got, "&") {
|
||||||
|
t.Errorf("expected escaped metacharacters, got %q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "<a ") {
|
||||||
|
t.Errorf("did not expect <a> tag in plain prose: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_SingleHTTPSURL(t *testing.T) {
|
||||||
|
got := autolinkText("see https://example.com for more")
|
||||||
|
want := `see <a href="https://example.com" rel="noopener">https://example.com</a> for more`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_HTTPNotLinked(t *testing.T) {
|
||||||
|
got := autolinkText("see http://example.com for more")
|
||||||
|
if strings.Contains(got, "<a ") {
|
||||||
|
t.Errorf("http:// should not be auto-linked, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "http://example.com") {
|
||||||
|
t.Errorf("expected URL to appear as plain escaped text, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_BareDomainNotLinked(t *testing.T) {
|
||||||
|
got := autolinkText("see example.com for more")
|
||||||
|
if strings.Contains(got, "<a ") {
|
||||||
|
t.Errorf("bare domain should not be auto-linked, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_TrailingPeriodOutsideAnchor(t *testing.T) {
|
||||||
|
got := autolinkText("visit https://example.com.")
|
||||||
|
want := `visit <a href="https://example.com" rel="noopener">https://example.com</a>.`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_TrailingPunctuationStripped(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
wantTail string
|
||||||
|
}{
|
||||||
|
{"check https://example.com,", ","},
|
||||||
|
{"is it https://example.com?", "?"},
|
||||||
|
{"wow https://example.com!", "!"},
|
||||||
|
{"so https://example.com;", ";"},
|
||||||
|
{"foo https://example.com:", ":"},
|
||||||
|
{`he said "https://example.com"`, `"`},
|
||||||
|
{"foo https://example.com'", "'"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := autolinkText(tc.input)
|
||||||
|
if !strings.Contains(got, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||||
|
t.Errorf("input %q: expected URL link without trailing punctuation, got %q", tc.input, got)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, tc.wantTail) {
|
||||||
|
t.Errorf("input %q: expected suffix %q, got %q", tc.input, tc.wantTail, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_ClosingParenOutsideAnchorWhenUnbalanced(t *testing.T) {
|
||||||
|
got := autolinkText("(see https://example.com)")
|
||||||
|
want := `(see <a href="https://example.com" rel="noopener">https://example.com</a>)`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_ClosingParenKeptWhenBalanced(t *testing.T) {
|
||||||
|
got := autolinkText("see https://en.wikipedia.org/wiki/Foo_(bar) page")
|
||||||
|
if !strings.Contains(got, `<a href="https://en.wikipedia.org/wiki/Foo_(bar)" rel="noopener">https://en.wikipedia.org/wiki/Foo_(bar)</a>`) {
|
||||||
|
t.Errorf("expected paren kept inside anchor when balanced, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_MultipleURLs(t *testing.T) {
|
||||||
|
got := autolinkText("first https://a.com then https://b.com end")
|
||||||
|
if strings.Count(got, "<a ") != 2 {
|
||||||
|
t.Errorf("expected 2 anchors, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `href="https://a.com"`) || !strings.Contains(got, `href="https://b.com"`) {
|
||||||
|
t.Errorf("expected both URLs linked, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_URLWithQueryStringContainingAmpersand(t *testing.T) {
|
||||||
|
got := autolinkText("see https://example.com/?a=1&b=2 ok")
|
||||||
|
// `&` should be escaped to `&` in both href and text
|
||||||
|
if !strings.Contains(got, `href="https://example.com/?a=1&b=2"`) {
|
||||||
|
t.Errorf("expected escaped ampersand in href, got %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "rel=\"noopener\"") {
|
||||||
|
t.Errorf("expected rel=noopener, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_URLAtStringStart(t *testing.T) {
|
||||||
|
got := autolinkText("https://example.com is great")
|
||||||
|
if !strings.HasPrefix(got, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||||
|
t.Errorf("expected anchor at start, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutolinkText_URLAtStringEnd(t *testing.T) {
|
||||||
|
got := autolinkText("checkout https://example.com")
|
||||||
|
want := `checkout <a href="https://example.com" rel="noopener">https://example.com</a>`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration tests: confirm autolinking flows through the public renderer
|
||||||
|
|
||||||
|
func TestBlockNoteToHTML_AutolinksURLInParagraph(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "visit https://example.com today"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||||
|
t.Errorf("expected autolinked URL in paragraph, got %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockNoteToHTML_NoNestedAnchorInsideExplicitLink(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "link",
|
||||||
|
"href": "https://short.url/",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "see https://full-url-text.com"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
// Outer link should exist
|
||||||
|
if !strings.Contains(html, `<a href="https://short.url/">`) {
|
||||||
|
t.Errorf("expected outer explicit link, got %s", html)
|
||||||
|
}
|
||||||
|
// Inner text must NOT become a nested anchor
|
||||||
|
if strings.Contains(html, `<a href="https://full-url-text.com"`) {
|
||||||
|
t.Errorf("did not expect nested anchor inside link, got %s", html)
|
||||||
|
}
|
||||||
|
// Inner URL should appear as escaped plain text
|
||||||
|
if !strings.Contains(html, "https://full-url-text.com") {
|
||||||
|
t.Errorf("expected inner URL as plain text, got %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": "https://example.com",
|
||||||
|
"styles": map[string]any{"code": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if strings.Contains(html, "<a ") {
|
||||||
|
t.Errorf("did not expect anchor inside code-styled text, got %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "<code>") || !strings.Contains(html, "https://example.com") {
|
||||||
|
t.Errorf("expected code-wrapped literal URL, got %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockNoteToHTML_BoldWrapsAutolink(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": "https://example.com",
|
||||||
|
"styles": map[string]any{"bold": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, `<strong><a href="https://example.com" rel="noopener">https://example.com</a></strong>`) {
|
||||||
|
t.Errorf("expected bold-wrapped autolink, got %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
640
render/blocknote_test.go
Normal file
640
render/blocknote_test.go
Normal file
@ -0,0 +1,640 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/blocks"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEmbedResolver struct{}
|
||||||
|
|
||||||
|
func (testEmbedResolver) RenderEmbed(_ context.Context, blockID uuid.UUID, dataSource, layout string) string {
|
||||||
|
return "embed:" + blockID.String() + ":" + dataSource + ":" + layout
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockNoteToHTMLUsesSDKContextResolvers(t *testing.T) {
|
||||||
|
blockID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||||
|
ctx := blocks.WithEmbedResolver(context.Background(), testEmbedResolver{})
|
||||||
|
ctx = blocks.WithHumanProofBanner(ctx, &blocks.HumanProofBannerData{
|
||||||
|
ActiveTimeMinutes: 12,
|
||||||
|
KeystrokeCount: 3456,
|
||||||
|
SessionCount: 2,
|
||||||
|
PostSlug: "proof-post",
|
||||||
|
})
|
||||||
|
|
||||||
|
html := BlockNoteToHTML(ctx, map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "embed",
|
||||||
|
"props": map[string]any{
|
||||||
|
"blockId": blockID.String(),
|
||||||
|
"dataSource": "row:22222222-2222-2222-2222-222222222222",
|
||||||
|
"layout": "card",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map[string]any{"type": "humanProof"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(html, "embed:"+blockID.String()+":row:22222222-2222-2222-2222-222222222222:card") {
|
||||||
|
t.Fatalf("BlockNoteToHTML() did not render embed from SDK context: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `data-human-proof-banner`) || !strings.Contains(html, `proof-post`) {
|
||||||
|
t.Fatalf("BlockNoteToHTML() did not render human proof banner from SDK context: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextAlignment(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
blockType string
|
||||||
|
alignment string
|
||||||
|
wantClass string
|
||||||
|
}{
|
||||||
|
{"paragraph center", "paragraph", "center", "text-center"},
|
||||||
|
{"paragraph right", "paragraph", "right", "text-right"},
|
||||||
|
{"heading center", "heading", "center", "text-center"},
|
||||||
|
{"quote justify", "quote", "justify", "text-justify"},
|
||||||
|
{"paragraph left", "paragraph", "left", "text-left"},
|
||||||
|
{"paragraph default", "paragraph", "", "my-4"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
props := map[string]any{}
|
||||||
|
if tt.alignment != "" {
|
||||||
|
props["textAlignment"] = tt.alignment
|
||||||
|
}
|
||||||
|
if tt.blockType == "heading" {
|
||||||
|
props["level"] = float64(2)
|
||||||
|
}
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": tt.blockType,
|
||||||
|
"props": props,
|
||||||
|
"content": "Test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, tt.wantClass) {
|
||||||
|
t.Errorf("expected class %q in output: %s", tt.wantClass, html)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckListItem(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "checkListItem",
|
||||||
|
"props": map[string]any{"checked": true},
|
||||||
|
"content": "Done task",
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "checkListItem",
|
||||||
|
"props": map[string]any{"checked": false},
|
||||||
|
"content": "Todo task",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `checked`) {
|
||||||
|
t.Errorf("expected checked attribute: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Done task") || !strings.Contains(html, "Todo task") {
|
||||||
|
t.Errorf("expected checklist content: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `type="checkbox"`) {
|
||||||
|
t.Errorf("expected checkbox input: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleListItem(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "toggleListItem",
|
||||||
|
"content": "Details",
|
||||||
|
"children": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": "Nested content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, "<details") {
|
||||||
|
t.Errorf("expected details element: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "<summary") {
|
||||||
|
t.Errorf("expected summary element: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Nested content") {
|
||||||
|
t.Errorf("expected nested content: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleListItemOpen(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "toggleListItem",
|
||||||
|
"props": map[string]any{"open": true},
|
||||||
|
"content": "Open toggle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, ` open`) {
|
||||||
|
t.Errorf("expected open attribute: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoBlock(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "video",
|
||||||
|
"props": map[string]any{
|
||||||
|
"url": "https://example.com/video.mp4",
|
||||||
|
"caption": "My video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `<video`) {
|
||||||
|
t.Errorf("expected video element: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `controls`) {
|
||||||
|
t.Errorf("expected controls attribute: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "My video") {
|
||||||
|
t.Errorf("expected caption: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAudioBlock(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "audio",
|
||||||
|
"props": map[string]any{
|
||||||
|
"url": "https://example.com/audio.mp3",
|
||||||
|
"caption": "Podcast episode",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `<audio`) {
|
||||||
|
t.Errorf("expected audio element: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `controls`) {
|
||||||
|
t.Errorf("expected controls attribute: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Podcast episode") {
|
||||||
|
t.Errorf("expected caption: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileBlock(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "file",
|
||||||
|
"props": map[string]any{
|
||||||
|
"url": "https://example.com/doc.pdf",
|
||||||
|
"name": "Document.pdf",
|
||||||
|
"caption": "Download here",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `href="https://example.com/doc.pdf"`) {
|
||||||
|
t.Errorf("expected file link: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Document.pdf") {
|
||||||
|
t.Errorf("expected file name: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Download here") {
|
||||||
|
t.Errorf("expected caption: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileBlockNoName(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "file",
|
||||||
|
"props": map[string]any{
|
||||||
|
"url": "https://example.com/doc.pdf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "https://example.com/doc.pdf") {
|
||||||
|
t.Errorf("expected URL as fallback name: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatementBlock(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "statement",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": "This is a key statement.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `class="bn-statement"`) {
|
||||||
|
t.Errorf("expected bn-statement class: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "This is a key statement.") {
|
||||||
|
t.Errorf("expected statement text: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatementBlockWithFormatting(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "statement",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Bold text",
|
||||||
|
"styles": map[string]any{"bold": true},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": " and normal.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, "<strong>Bold text</strong>") {
|
||||||
|
t.Errorf("expected bold formatting: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColorValueToClass(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
prefix string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty", "", "text", ""},
|
||||||
|
{"default", "default", "text", ""},
|
||||||
|
{"theme primary text", "primary", "text", "text-primary"},
|
||||||
|
{"theme primary bg", "primary", "bg", "bg-primary"},
|
||||||
|
{"theme foreground", "foreground", "text", "text-foreground"},
|
||||||
|
{"theme muted-foreground", "muted-foreground", "text", "text-muted-foreground"},
|
||||||
|
{"custom color text", "custom:brand", "text", "text-[hsl(var(--color-brand))]"},
|
||||||
|
{"custom color bg", "custom:brand", "bg", "bg-[hsl(var(--color-brand))]"},
|
||||||
|
{"hex color text", "hex:#ff5500", "text", "text-[#ff5500]"},
|
||||||
|
{"hex color bg", "hex:#ff5500", "bg", "bg-[#ff5500]"},
|
||||||
|
{"short hex", "hex:#f00", "text", "text-[#f00]"},
|
||||||
|
{"invalid hex length", "hex:#ff", "text", ""},
|
||||||
|
{"invalid hex char", "hex:#gggggg", "text", ""},
|
||||||
|
{"custom with invalid chars", "custom:bad name", "text", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := colorValueToClass(tt.input, tt.prefix)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("colorValueToClass(%q, %q) = %q, want %q", tt.input, tt.prefix, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderInlineContentWithTextColor(t *testing.T) {
|
||||||
|
content := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Hello",
|
||||||
|
"styles": map[string]any{
|
||||||
|
"textColor": "primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `class="text-primary"`) {
|
||||||
|
t.Errorf("expected text color class: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderInlineContentWithBackgroundColor(t *testing.T) {
|
||||||
|
content := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Highlighted",
|
||||||
|
"styles": map[string]any{
|
||||||
|
"backgroundColor": "hex:#ffcc00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `bg-[#ffcc00]`) {
|
||||||
|
t.Errorf("expected background color class: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderInlineContentWithBothColors(t *testing.T) {
|
||||||
|
content := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Styled",
|
||||||
|
"styles": map[string]any{
|
||||||
|
"textColor": "foreground",
|
||||||
|
"backgroundColor": "custom:highlight",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `text-foreground`) {
|
||||||
|
t.Errorf("expected text color class: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `bg-[hsl(var(--color-highlight))]`) {
|
||||||
|
t.Errorf("expected background color class: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderInlineContentWithDefaultColor(t *testing.T) {
|
||||||
|
content := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Normal",
|
||||||
|
"styles": map[string]any{
|
||||||
|
"textColor": "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
|
if strings.Contains(html, "class=") {
|
||||||
|
t.Errorf("default color should not add class: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderInlineContentWithColorsAndOtherStyles(t *testing.T) {
|
||||||
|
content := []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Bold and colored",
|
||||||
|
"styles": map[string]any{
|
||||||
|
"bold": true,
|
||||||
|
"textColor": "hex:#ff0000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := renderInlineContent(content, false)
|
||||||
|
|
||||||
|
if !strings.Contains(html, "<strong>") {
|
||||||
|
t.Errorf("expected bold tag: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `text-[#ff0000]`) {
|
||||||
|
t.Errorf("expected text color class: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockNoteToHTMLWithColoredParagraph(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Red text",
|
||||||
|
"styles": map[string]any{
|
||||||
|
"textColor": "hex:#ff0000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, "<p") {
|
||||||
|
t.Errorf("expected paragraph tag: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `text-[#ff0000]`) {
|
||||||
|
t.Errorf("expected text color class: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulletListStringContent(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "bulletListItem",
|
||||||
|
"content": "Test bullet content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "Test bullet content") {
|
||||||
|
t.Errorf("expected bullet content: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableSingleRowDoesNotEmitTbody(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "table",
|
||||||
|
"content": map[string]any{
|
||||||
|
"rows": []any{
|
||||||
|
map[string]any{
|
||||||
|
"cells": []any{
|
||||||
|
[]any{map[string]any{"type": "text", "text": "Header"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "<thead>") {
|
||||||
|
t.Errorf("expected thead: %s", html)
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "<tbody>") {
|
||||||
|
t.Errorf("did not expect tbody for single-row table: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableCellStringContent(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "table",
|
||||||
|
"content": map[string]any{
|
||||||
|
"rows": []any{
|
||||||
|
map[string]any{"cells": []any{"Header", "Value"}},
|
||||||
|
map[string]any{"cells": []any{"Row", "Cell"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "Header") || !strings.Contains(html, "Cell") {
|
||||||
|
t.Errorf("expected table cell content: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "<tbody>") {
|
||||||
|
t.Errorf("expected tbody for multi-row table: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedListRendering(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "bulletListItem",
|
||||||
|
"content": "Parent",
|
||||||
|
"children": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "bulletListItem",
|
||||||
|
"content": "Child",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "Parent") || !strings.Contains(html, "Child") {
|
||||||
|
t.Errorf("expected nested list content: %s", html)
|
||||||
|
}
|
||||||
|
if strings.Count(html, "<ul") < 2 {
|
||||||
|
t.Errorf("expected nested ul elements: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHardBreakInlineContent(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "paragraph",
|
||||||
|
"content": []any{
|
||||||
|
map[string]any{"type": "text", "text": "Line1"},
|
||||||
|
map[string]any{"type": "hardBreak"},
|
||||||
|
map[string]any{"type": "text", "text": "Line2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "Line1") || !strings.Contains(html, "Line2") || !strings.Contains(html, "<br />") {
|
||||||
|
t.Errorf("expected hard break in output: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferencesBlock(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "references",
|
||||||
|
"props": map[string]any{
|
||||||
|
"items": `[{"text":"Steve Blank","url":"https://example.com/blank"},{"text":"Charity Majors","url":""}]`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `class="bn-references"`) {
|
||||||
|
t.Errorf("expected bn-references class: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `<a href="https://example.com/blank">Steve Blank`) {
|
||||||
|
t.Errorf("expected linked reference: %s", html)
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "<li>Charity Majors") {
|
||||||
|
t.Errorf("expected plain text reference: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferencesBlockEmpty(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "references",
|
||||||
|
"props": map[string]any{"items": "[]"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, `class="bn-references"`) {
|
||||||
|
t.Errorf("expected bn-references wrapper even when empty: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferencesBlockMalformedJSON(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "references",
|
||||||
|
"props": map[string]any{"items": "not valid json"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, `class="bn-references"`) {
|
||||||
|
t.Errorf("expected graceful fallback: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyDocument(t *testing.T) {
|
||||||
|
html := BlockNoteToHTML(context.Background(), map[string]any{})
|
||||||
|
if html != "" {
|
||||||
|
t.Errorf("expected empty output for empty doc: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownBlockType(t *testing.T) {
|
||||||
|
doc := map[string]any{
|
||||||
|
"blocks": []any{
|
||||||
|
map[string]any{
|
||||||
|
"type": "unknownBlock",
|
||||||
|
"content": "Some content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
html := BlockNoteToHTML(context.Background(), doc)
|
||||||
|
if !strings.Contains(html, "Some content") {
|
||||||
|
t.Errorf("expected fallback rendering of unknown block: %s", html)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
templates/pongo/base.html
Normal file
9
templates/pongo/base.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="{{ theme_mode }}">
|
||||||
|
{{ head_html|safe }}
|
||||||
|
<body class="{% block body_class %}bg-background text-foreground antialiased min-h-screen flex flex-col{% endblock %}">
|
||||||
|
{{ admin_banner_html|safe }}
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
{{ body_end_html|safe }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
templates/pongo/context.go
Normal file
100
templates/pongo/context.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package pongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
|
|
||||||
|
"github.com/flosch/pongo2/v6"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/blocks"
|
||||||
|
"git.dev.alexdunmow.com/block/core/templates/bn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildPageContext builds a pongo2.Context with pre-rendered head/body HTML
|
||||||
|
// and all page-level variables from the standard doc map.
|
||||||
|
func (e *Engine) buildPageContext(ctx context.Context, doc map[string]any) pongo2.Context {
|
||||||
|
title := "Untitled"
|
||||||
|
if t, ok := doc["title"].(string); ok && t != "" {
|
||||||
|
title = t
|
||||||
|
}
|
||||||
|
|
||||||
|
slots := make(map[string]string)
|
||||||
|
if s, ok := doc["slots"].(map[string]string); ok {
|
||||||
|
slots = s
|
||||||
|
}
|
||||||
|
|
||||||
|
themeMode := "dark"
|
||||||
|
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
|
||||||
|
themeMode = tm
|
||||||
|
}
|
||||||
|
|
||||||
|
themeCSS := ""
|
||||||
|
if tc, ok := doc["theme_css"].(string); ok {
|
||||||
|
themeCSS = tc
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredData := ""
|
||||||
|
if sd, ok := doc["structured_data"].(string); ok {
|
||||||
|
structuredData = sd
|
||||||
|
}
|
||||||
|
|
||||||
|
cssHash := ""
|
||||||
|
if ch, ok := doc["css_hash"].(string); ok {
|
||||||
|
cssHash = ch
|
||||||
|
}
|
||||||
|
|
||||||
|
pageviewNonce := ""
|
||||||
|
if pn, ok := doc["pageview_nonce"].(string); ok {
|
||||||
|
pageviewNonce = pn
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := bn.ParseSiteSettings(doc)
|
||||||
|
pageMeta := bn.ParsePageMeta(doc)
|
||||||
|
engagementConfig := bn.ParseEngagementConfig(doc)
|
||||||
|
|
||||||
|
headHTML := renderComponent(ctx, bn.Head(bn.HeadData{
|
||||||
|
Title: title,
|
||||||
|
Settings: settings,
|
||||||
|
PageMeta: pageMeta,
|
||||||
|
ThemeMode: themeMode,
|
||||||
|
ThemeCSS: themeCSS,
|
||||||
|
PluginStyles: e.stylePaths,
|
||||||
|
StructuredData: structuredData,
|
||||||
|
CSSHash: cssHash,
|
||||||
|
PageviewNonce: pageviewNonce,
|
||||||
|
EngagementConfig: engagementConfig,
|
||||||
|
}))
|
||||||
|
bodyEndHTML := renderComponent(ctx, bn.BodyEnd(settings))
|
||||||
|
bannerHTML := renderComponent(ctx, bn.AdminBypassBanner(settings))
|
||||||
|
|
||||||
|
slotsAny := make(map[string]any, len(slots))
|
||||||
|
for k, v := range slots {
|
||||||
|
slotsAny[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return pongo2.Context{
|
||||||
|
"head_html": headHTML,
|
||||||
|
"body_end_html": bodyEndHTML,
|
||||||
|
"admin_banner_html": bannerHTML,
|
||||||
|
|
||||||
|
"title": title,
|
||||||
|
"slots": slotsAny,
|
||||||
|
"theme_mode": themeMode,
|
||||||
|
"theme_css": themeCSS,
|
||||||
|
"css_hash": cssHash,
|
||||||
|
|
||||||
|
"site_settings": settings,
|
||||||
|
"page_meta": pageMeta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBlockContext builds a pongo2.Context for block rendering.
|
||||||
|
// Content fields are available directly; request context is under "ctx".
|
||||||
|
func buildBlockContext(ctx context.Context, content map[string]any) pongo2.Context {
|
||||||
|
pongoCtx := make(pongo2.Context, len(content)+1)
|
||||||
|
maps.Copy(pongoCtx, content)
|
||||||
|
if bc := blocks.GetBlockContext(ctx); bc != nil {
|
||||||
|
pongoCtx["ctx"] = bc.ToMap()
|
||||||
|
}
|
||||||
|
return pongoCtx
|
||||||
|
}
|
||||||
6
templates/pongo/embed.go
Normal file
6
templates/pongo/embed.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package pongo
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed base.html
|
||||||
|
var baseFS embed.FS
|
||||||
169
templates/pongo/engine.go
Normal file
169
templates/pongo/engine.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package pongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"maps"
|
||||||
|
|
||||||
|
"github.com/flosch/pongo2/v6"
|
||||||
|
|
||||||
|
"git.dev.alexdunmow.com/block/core/blocks"
|
||||||
|
"git.dev.alexdunmow.com/block/core/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine provides first-class pongo2 template support for BlockNinja plugins.
|
||||||
|
// Plugins create an Engine with their embedded template FS, then call
|
||||||
|
// MustPageTemplate / MustBlockTemplate to get functions compatible with
|
||||||
|
// the template and block registries.
|
||||||
|
type Engine struct {
|
||||||
|
set *pongo2.TemplateSet
|
||||||
|
stylePaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine creates a pongo2 Engine.
|
||||||
|
// pluginFS should contain the plugin's .html templates.
|
||||||
|
// stylePaths are CSS URLs included in the page <head> via bn.Head.
|
||||||
|
func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine {
|
||||||
|
loader := &multiLoader{
|
||||||
|
loaders: []pongo2.TemplateLoader{
|
||||||
|
&fsLoader{fsys: pluginFS},
|
||||||
|
&fsLoader{fsys: baseFS},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
set := pongo2.NewSet("plugin", loader)
|
||||||
|
return &Engine{set: set, stylePaths: stylePaths}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pongoComponent wraps a pongo2 template execution as an HTMLComponent.
|
||||||
|
type pongoComponent struct {
|
||||||
|
tpl *pongo2.Template
|
||||||
|
ctx pongo2.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
|
||||||
|
return c.tpl.ExecuteWriter(c.ctx, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustPageTemplate parses a pongo2 page template and returns a TemplateFunc.
|
||||||
|
// Panics on parse error.
|
||||||
|
//
|
||||||
|
// Available context variables in page templates:
|
||||||
|
//
|
||||||
|
// {{ head_html|safe }} — full <head> element
|
||||||
|
// {{ body_end_html|safe }} — body-end scripts and admin toolbar
|
||||||
|
// {{ admin_banner_html|safe }} — maintenance/coming-soon admin banner
|
||||||
|
// {{ title }} — page title
|
||||||
|
// {{ slots.header|safe }} — rendered slot HTML
|
||||||
|
// {{ theme_mode }} — "light", "dark", or "system"
|
||||||
|
// {{ theme_css }} — raw CSS custom properties
|
||||||
|
// {{ css_hash }} — cache-busting hash
|
||||||
|
// {{ site_settings }} — bn.SiteSettingsData struct
|
||||||
|
// {{ page_meta }} — bn.PageMeta struct
|
||||||
|
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
||||||
|
tpl := pongo2.Must(e.set.FromFile(name))
|
||||||
|
return func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
|
||||||
|
pongoCtx := e.buildPageContext(ctx, doc)
|
||||||
|
return &pongoComponent{tpl: tpl, ctx: pongoCtx}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustBlockTemplate parses a pongo2 block template and returns a BlockFunc.
|
||||||
|
// Panics on parse error.
|
||||||
|
//
|
||||||
|
// The content map fields are available directly as template variables.
|
||||||
|
// Request context is available under {{ ctx.url }}, {{ ctx.isEditor }}, etc.
|
||||||
|
func (e *Engine) MustBlockTemplate(name string) blocks.BlockFunc {
|
||||||
|
tpl := pongo2.Must(e.set.FromFile(name))
|
||||||
|
return func(ctx context.Context, content map[string]any) string {
|
||||||
|
pongoCtx := buildBlockContext(ctx, content)
|
||||||
|
out, err := tpl.Execute(pongoCtx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return blocks.ProcessIcons(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustBlockTemplateWithDefaults is like MustBlockTemplate but merges default
|
||||||
|
// values before rendering. Content keys override defaults.
|
||||||
|
func (e *Engine) MustBlockTemplateWithDefaults(name string, defaults map[string]any) blocks.BlockFunc {
|
||||||
|
tpl := pongo2.Must(e.set.FromFile(name))
|
||||||
|
return func(ctx context.Context, content map[string]any) string {
|
||||||
|
merged := make(map[string]any, len(defaults)+len(content))
|
||||||
|
maps.Copy(merged, defaults)
|
||||||
|
maps.Copy(merged, content)
|
||||||
|
pongoCtx := buildBlockContext(ctx, merged)
|
||||||
|
out, err := tpl.Execute(pongoCtx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return blocks.ProcessIcons(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustTemplateOverride parses a pongo2 template for use as a block template
|
||||||
|
// override (e.g., custom heading/text styling). Same as MustBlockTemplate
|
||||||
|
// but named distinctly for clarity at the call site.
|
||||||
|
func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
|
||||||
|
return e.MustBlockTemplate(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustEmailWrapper parses a pongo2 email template and returns an
|
||||||
|
// EmailWrapperFunc. Panics on parse error.
|
||||||
|
//
|
||||||
|
// Available context variables:
|
||||||
|
//
|
||||||
|
// {{ body|safe }} — the email body HTML
|
||||||
|
// {{ site_name }} — site name
|
||||||
|
// {{ site_url }} — site URL
|
||||||
|
// {{ logo_url }} — site logo URL
|
||||||
|
// {{ unsubscribe_url }} — unsubscribe link (may be empty)
|
||||||
|
// {{ preview_text }} — email preview/preheader text
|
||||||
|
// {{ colors.primary }} — primary hex color
|
||||||
|
// {{ colors.secondary }} — secondary hex color
|
||||||
|
// {{ colors.background }} — background hex color
|
||||||
|
// {{ colors.foreground }} — foreground hex color
|
||||||
|
// {{ colors.border }} — border hex color
|
||||||
|
// {{ colors.muted }} — muted hex color
|
||||||
|
// (and all other EmailColors fields as lowercase keys)
|
||||||
|
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
||||||
|
tpl := pongo2.Must(e.set.FromFile(name))
|
||||||
|
return func(body string, ctx templates.EmailContext) string {
|
||||||
|
pongoCtx := pongo2.Context{
|
||||||
|
"body": body,
|
||||||
|
"site_name": ctx.SiteSettings.SiteName,
|
||||||
|
"site_url": ctx.SiteSettings.SiteURL,
|
||||||
|
"logo_url": ctx.SiteSettings.LogoURL,
|
||||||
|
"support_email": ctx.SiteSettings.SupportEmail,
|
||||||
|
"unsubscribe_url": ctx.UnsubscribeURL,
|
||||||
|
"preview_text": ctx.PreviewText,
|
||||||
|
"colors": map[string]any{
|
||||||
|
"primary": ctx.Colors.Primary,
|
||||||
|
"primaryForeground": ctx.Colors.PrimaryForeground,
|
||||||
|
"secondary": ctx.Colors.Secondary,
|
||||||
|
"secondaryForeground": ctx.Colors.SecondaryForeground,
|
||||||
|
"background": ctx.Colors.Background,
|
||||||
|
"foreground": ctx.Colors.Foreground,
|
||||||
|
"muted": ctx.Colors.Muted,
|
||||||
|
"mutedForeground": ctx.Colors.MutedForeground,
|
||||||
|
"border": ctx.Colors.Border,
|
||||||
|
"card": ctx.Colors.Card,
|
||||||
|
"cardForeground": ctx.Colors.CardForeground,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := tpl.Execute(pongoCtx)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderComponent renders any HTMLComponent to a string.
|
||||||
|
func renderComponent(ctx context.Context, c templates.HTMLComponent) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = c.Render(ctx, &buf)
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
43
templates/pongo/loader.go
Normal file
43
templates/pongo/loader.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package pongo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"github.com/flosch/pongo2/v6"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fsLoader adapts an fs.FS to pongo2's TemplateLoader interface.
|
||||||
|
type fsLoader struct {
|
||||||
|
fsys fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *fsLoader) Abs(base, name string) string {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *fsLoader) Get(path string) (io.Reader, error) {
|
||||||
|
return l.fsys.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiLoader chains multiple loaders, returning the first hit.
|
||||||
|
// Plugin templates can {% extends "base.html" %} where base.html
|
||||||
|
// lives in the core embedded FS rather than the plugin FS.
|
||||||
|
type multiLoader struct {
|
||||||
|
loaders []pongo2.TemplateLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *multiLoader) Abs(base, name string) string {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *multiLoader) Get(path string) (io.Reader, error) {
|
||||||
|
for _, loader := range l.loaders {
|
||||||
|
r, err := loader.Get(path)
|
||||||
|
if err == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("pongo: template %q not found in any loader", path)
|
||||||
|
}
|
||||||
@ -2,12 +2,17 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"github.com/a-h/templ"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// HTMLComponent is the generic interface for rendered template output.
|
||||||
|
// Both templ.Component and pongo2 engine output satisfy this interface.
|
||||||
|
type HTMLComponent interface {
|
||||||
|
Render(ctx context.Context, w io.Writer) error
|
||||||
|
}
|
||||||
|
|
||||||
// TemplateFunc is the signature for template functions loaded from plugins.
|
// TemplateFunc is the signature for template functions loaded from plugins.
|
||||||
type TemplateFunc func(ctx context.Context, doc map[string]any) templ.Component
|
type TemplateFunc func(ctx context.Context, doc map[string]any) HTMLComponent
|
||||||
|
|
||||||
// PageTemplateMeta provides metadata about a page template within a system template.
|
// PageTemplateMeta provides metadata about a page template within a system template.
|
||||||
type PageTemplateMeta struct {
|
type PageTemplateMeta struct {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user