Compare commits

...

63 Commits
v0.6.0 ... main

Author SHA1 Message Date
Alex Dunmow
aa66c2f409 chore: update proto submodule to merged main
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 20:54:07 +08:00
Alex Dunmow
f4e579ad7a refactor: simplify pongo template context and engine, add private plugins spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 20:49:18 +08:00
Alex Dunmow
26b262ce73 feat(ninja): wire ListTags into popular-tag prompt and list helpers 2026-06-07 15:53:03 +08:00
Alex Dunmow
bb3ddfe1bd refactor(ninja): extract popular-tags stubs into named helpers 2026-06-07 15:38:24 +08:00
Alex Dunmow
5d368da839 test(ninja): plugin tags subcommand round-trip
Adds TestMutateTags_AddRmSetClear (covers add/rm/set/clear operations
including dedupe and normalisation) and TestMutateTags_RejectsInvalidNoWrite
(ensures validation failures don't mutate plugin.mod).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:34:36 +08:00
Alex Dunmow
e16655aed8 refactor(ninja): use slices.Equal in mutateTags 2026-06-07 15:33:38 +08:00
Alex Dunmow
03d32aba26 feat(ninja): add 'plugin tags' subcommand
Adds ninja plugin tags (show), add, rm, set, and clear subcommands that
read and mutate the local plugin.mod tag list via NormalizeTags. The bare
tags command stubs ListTags with a TODO(tags): marker for Task 12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:31:36 +08:00
Alex Dunmow
533632a3bb feat(ninja): prompt for tags in plugin init/edit flow
Add promptTagsWithDefault helper (mirrors promptCategoriesWithDefault) and
wire it into the init flow so upsertPluginMod receives real user-entered tags
instead of nil. ListTags RPC stub left with TODO(tags) marker for Task 12.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:27:30 +08:00
Alex Dunmow
6bc0f98979 refactor(ninja): thread tags through upsertPluginMod
Extend upsertPluginMod signature to accept tags parameter (positional arg 7,
between categories and private). Update the single call site to pass nil.
Add tags serialization in writeMod, mirroring the categories pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:25:00 +08:00
Alex Dunmow
d53c3d8325 feat(plugin): add Tags field to ModPlugin
TDD approach: added failing tests in mod_test.go that check parsing and
null-coalescing of tags, then added the []string Tags field to ModPlugin
struct with TOML tag "tags,omitempty".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 15:23:05 +08:00
Alex Dunmow
ed365f9030 feat(plugin): add NormalizeTags helper and slug rules
Add NormalizeTags, TagMinLen, TagMaxLen, TagMaxCount constants and
tagSlugRe to plugin/mod.go. Full TDD: 8 tests covering happy path,
trim/lowercase, case-insensitive dedupe, empty-drop, bad-slug rejection,
boundary acceptance, cap enforcement, and nil input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:20:24 +08:00
Alex Dunmow
ba87684696 feat(plugin): add RequiredIconPacks to PluginRegistration and ModPlugin
Lets plugins declare icon-pack dependencies (e.g. "tabler", "phosphor")
in plugin.mod and PluginRegistration. The CMS loader auto-installs
declared packs from the bundled registry before the plugin loads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 15:14:15 +08:00
Alex Dunmow
c3c7b2d441 docs: follow CMS module rename
CMS Go module renamed from git.dev.alexdunmow.com/block/ninja to
git.dev.alexdunmow.com/block/cms (along with the git remote rename
on gitea). All import paths, string-literal rules, test fixtures,
and doc references updated; (historical 'ninja-orchestrator' refs
preserved). go mod tidy regenerated checksums.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:55:49 +08:00
Alex Dunmow
48c54814ec docs: update paths after 2026-06-06 repo consolidation
Repos consolidated under ~/src/blockninja/ parent (collection, not
monorepo). This repo moved from ~/src/core to ~/src/blockninja/core.
Updates historical plan/audit docs that referenced the old paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:02:51 +08:00
Alex Dunmow
af7f44c34d fix(plugin): drop VisibilityLabel and its proto import
When core/plugin imported core/internal/api/orchestrator/v1 for the
PluginVisibility enum, every consumer of core/plugin (including the
orchestrator) transitively pulled in core's generated bindings — and
those bindings register the same proto descriptors as the orchestrator's
own bindings, panicking at startup.

Move the label helper into the CLI's cmd package where it belongs;
core/plugin no longer references the proto package at all.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:58:09 +08:00
Alex Dunmow
7fc20a990b fix(cli): use renamed ListMyAccountsForCLI RPC
Tracks the shared proto rename that resolves the message-name collision
between PluginAuthService and AccountService.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:55:10 +08:00
Alex Dunmow
051253396c fix(proto): narrow core generation to plugin_registry.proto only
When the orchestrator imports `block/core/internal/api/orchestrator/v1`
transitively through other core packages and also generates its own
bindings for the same files (accounts.proto etc.), proto registration
panics at startup: "file ... is already registered". Tests in the
orchestrator confirmed this.

Fix:
- buf generate now uses --path to limit core's output to
  proto/orchestrator/v1/plugin_registry.proto (see new `make proto`).
- Adds a minimal MyAccount message and PluginAuthService.ListMyAccounts
  RPC to plugin_registry.proto (already pushed to block/proto) so the
  CLI's account picker no longer needs accounts.proto generated.
- CLI switches back to cli.Auth.ListMyAccounts; orchclient.Client drops
  the Account field.

Side effect: every previously-generated orchestrator/v1 binding besides
plugin_registry is removed from this module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:53:44 +08:00
Alex Dunmow
35436581b9 feat(proto): consume canonical block/proto as a submodule
Remove core's local proto/ fork and pull the canonical block/proto repo in
as a submodule at the same path. buf.yaml now sources from the submodule's
orchestrator/v1 namespace; everything outside that (blockninja, helpdesk)
is excluded from generation.

This brings the orchestrator's local-only RPCs (PluginScopeService.ListMyPlugins,
PluginRegistryService.SubmitForReview, the full PluginModerationService) into
core's bindings — harmless surface area for the CLI, prerequisite for the
orchestrator to also stop forking the proto.

Side effect: the CLI's account picker now uses the canonical
AccountService.ListMyAccounts in accounts.proto rather than the duplicate
PluginAuthService.ListMyAccounts that lived only in core's fork. The
existing Account message uses Name (no DisplayName / Role), so the picker
output collapses to "slug — Name".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:45:47 +08:00
Alex Dunmow
c390e16b5c build(make): auto-bump release version + distribute-sdk subcommand
- `make release` (no args) infers the next version from the last vX.Y.Z
  tag using conventional-commits: BREAKING/`!:` → major, `feat` → minor,
  otherwise → patch. LEVEL=major|minor|patch forces; VERSION=vX.Y.Z is
  the explicit escape hatch.
- New `distribute-sdk` subcommand commits + pushes the pin bump in each
  downstream. Surgical (commits go.mod + go.sum only) so any unrelated
  WIP in a downstream is left alone instead of getting swept into the
  commit. Repos without an origin remote land the commit locally.
- `release` now chains tag → push → update-sdk → distribute-sdk so one
  command takes the ecosystem from new commit to fully-distributed.
2026-06-04 20:18:26 +08:00
Alex Dunmow
87910e22ff fix(make): check-sdk-pins matches single-line require form
The previous regex only matched indented entries inside a `require (...)`
block, so single-line requires (like blockninja-themes/lcars uses) were
reported as unpinned even when the version was correct.
2026-06-04 08:53:17 +08:00
Alex Dunmow
c4d00a11d9 build(make): release target + expand SDK_DOWNSTREAM_DIRS
- `make release VERSION=vX.Y.Z` checks the tree is clean, pushes HEAD,
  tags, pushes the tag, then runs update-sdk so every downstream repo's
  go.mod gets bumped in one shot.
- SDK_DOWNSTREAM_DIRS now includes orchestrator/backend and
  blockninja-themes/* (globbed), which were both missing previously.
2026-06-04 08:49:28 +08:00
Alex Dunmow
7615bd92ca feat(cli): multi-account login, private-plugin SDK, publish dirty handling
- ninja login forces account selection (interactive when >1); creds now
  carry ActiveAccountID/Slug. New `ninja account` group.
- ninja plugin list / delete / delete-version split public vs active-account
  @private sections; `publish --private` is sticky in plugin.mod.
- GetPluginRequest gains active_account_id so @private resolution works
  alongside the public (scope, name) path.
- publish auto-commits a dirty plugin.mod (path-scoped, leaves other staged
  paths alone) so the bump→publish loop never trips the dirty check.
  --allow-dirty is replaced with --strict (default now ships dirty trees
  via stash-create).
- bump auto-commits its plugin.mod write with `bump to X.Y.Z`; --no-commit
  opts out.
- Design doc updated to match the new defaults.
2026-06-04 08:49:23 +08:00
Alex Dunmow
264116f44e feat(core): private-plugin SDK, PluginVisibility enum, and Go 1.26.4 bump
Add private-plugin RPCs (ListPrivatePlugins, DeletePrivatePlugin,
DeletePrivatePluginVersion, ListPrivatePluginInstallSites) and
ListMyAccounts to the proto/generated stubs; introduce PluginVisibility
enum replacing the loose string field; add ModPlugin.Private + Coords()
routing to @private/<name>@<version>; update ninja CLI to use
VisibilityLabel helper; bump go directive to 1.26.4 for ABI alignment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 01:00:50 +08:00
Alex Dunmow
06cabd6eb9 fix(cli): init prompts default to existing plugin.mod values 2026-06-03 12:01:56 +08:00
Alex Dunmow
041a7c2e3f feat(core): plugin display_name + description; CLI prompts; SDK fields 2026-06-03 11:31:23 +08:00
Alex Dunmow
dae3aa918a feat(core): sync plugin_registry proto with canonical (DenyDevice, GetDeviceStatus) 2026-06-03 10:57:07 +08:00
Alex Dunmow
46e3389045 test(cli): cover emitPublishWarnings across all three warning paths
Proves the publish command's warning surface end-to-end: tracked-yet-
gitignored files, declared submodules, untracked files on --allow-dirty,
and that the dirty-tree abort suppresses the untracked warning.
2026-06-03 10:15:24 +08:00
Alex Dunmow
824d55a1fa refactor(cli): extract publish-time warnings into emitPublishWarnings
Pulls the three warning calls and the dirty-tree check out of the
publish RunE closure into a single helper so a refactor that drops one
warning can be caught by a fixture-based test.
2026-06-03 10:14:50 +08:00
Alex Dunmow
ea744888ae test(cli): lock in trailing newline on gitignored-tracked warning
Guards against a silent refactor from Fprintln to Fprint dropping the
terminating newline that downstream output relies on.
2026-06-03 10:14:06 +08:00
Alex Dunmow
3d62071f77 test(cli): cover autoCommitPluginMod detached HEAD and missing git
Adds coverage for two git-edge cases: commits land correctly on a
detached HEAD with the prior commit as parent, and an empty PATH
produces a git-mentioning error rather than a panic.
2026-06-03 10:13:53 +08:00
Alex Dunmow
e076a03c33 test(cli): cover publish-time warning helpers
Add fires/no-op pairs for gitignoredTrackedWarning, untrackedFilesWarning,
and submoduleWarning. The submodule case writes a hand-crafted .gitmodules
file rather than wiring real submodules — submodulePaths reads the file
directly so that's sufficient.
2026-06-03 09:13:00 +08:00
Alex Dunmow
fda01e81b5 refactor(cli): extract publish-time warnings into testable helpers
Pull the three inline warning blocks in newPluginPublishCmd —
gitignoredTrackedWarning, untrackedFilesWarning, submoduleWarning — into
package-private helpers that take a repo dir and an io.Writer. Output is
byte-identical to the previous inline code; this just makes them unit-
testable without driving the whole cobra command.
2026-06-03 09:12:56 +08:00
Alex Dunmow
421f5ee0cb test(cli): cover autoCommitPluginMod commit and no-op paths
autoCommitPluginMod runs `git status --porcelain plugin.mod` then commits if
dirty. Add two cases: dirty plugin.mod produces an "Add plugin.mod" commit,
and a clean state leaves HEAD unchanged. Uses t.Chdir to scope CWD to the
temp repo without polluting parent state.
2026-06-03 09:11:37 +08:00
Alex Dunmow
ee76d76dc6 test(cli): cover BuildSourceArchive dirty-tree stash-create path
The dirty-tree branch (where git stash create captures uncommitted tracked
changes) was untested. Add two cases: one asserting the archive contains
the dirty working-copy contents (not HEAD) and the working tree is not
mutated; another asserting untracked files are excluded — the contract
the --allow-dirty publish warning relies on.
2026-06-03 09:11:08 +08:00
Alex Dunmow
ab465ef07c fix(cli): run gitignore-tracked warning before dirty-check so it fires unconditionally 2026-06-03 08:59:41 +08:00
Alex Dunmow
137a50c932 fix(cli): warn when publishing a repo that contains submodules
`git archive` does not recurse into submodules, so a plugin shipping
vendored code via submodule produced a tarball where the submodule path
existed but was empty — silent failure. Now publish reads .gitmodules
and lists submodule paths to stderr with guidance to vendor or pack
them separately. The publish still proceeds, since the developer may
not actually need the submodule contents in the archive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:57:46 +08:00
Alex Dunmow
c3cfa18ae0 fix(cli): warn about untracked files when publishing with --allow-dirty
`git stash create` only captures tracked content, so a developer using
--allow-dirty after creating new files (but forgetting to `git add`)
would ship a tarball missing them with no indication. Now publish lists
the untracked, non-ignored files to stderr and suggests `git add` when
--allow-dirty is in play.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:56:43 +08:00
Alex Dunmow
4c0104619e fix(cli): friendly error when publishing from a repo with no commits
Previously a brand-new repo (git init, no commits) surfaced `git stash
create: exit status 128: You do not have the initial commit yet` from
deep inside the archive helper. Now the publish flow detects this case
via `git rev-parse --verify HEAD` up front and prints "no commits in
repository; run `git add . && git commit` before publishing". Also
updates the init flow's hint to mention `git init && git commit` so
users aren't misled into thinking `git init` alone is enough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:56:27 +08:00
Alex Dunmow
20a7b35e50 docs(sdk): document Coords scope normalisation and accept-both contract
Adds a doc comment to ModFile.Coords explaining the leading-@ trim and a
note on ModPlugin.Scope clarifying that consumers should trim "@" before
comparing. Locks in the contract with a test asserting both call shapes
produce the same display string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:55:46 +08:00
Alex Dunmow
7af42c1c83 docs(uat): tick all 86 UAT boxes after end-to-end observation 2026-06-03 07:26:13 +08:00
Alex Dunmow
d1c194ce66 fix(cli): archive working tree (via stash create) so --allow-dirty publishes uncommitted plugin.mod 2026-06-03 07:07:10 +08:00
Alex Dunmow
139d9b8543 fix(sdk): Coords trims leading @ from scope so callers can store either form 2026-06-03 07:03:19 +08:00
Alex Dunmow
12afdbd25e fix(cli): store scope without @ in plugin.mod so Coords() yields single @ 2026-06-03 07:02:35 +08:00
Alex Dunmow
f232effe69 feat(cli): init prompts for kind and categories 2026-06-03 06:56:07 +08:00
Alex Dunmow
a79aa709c2 feat(core): mirror Plugin.kind + ListCategories proto regen 2026-06-03 01:44:26 +08:00
Alex Dunmow
08be22ec34 feat(sdk): add Kind and Categories to ModPlugin; writeMod emits them 2026-06-03 01:41:56 +08:00
Alex Dunmow
aafdc44f6f fix(cli): drop duplicated version in publish output (Coords already includes it) 2026-06-03 01:41:03 +08:00
Alex Dunmow
57a217f54d feat(cli): warn at publish when tracked files match .gitignore 2026-06-03 01:35:04 +08:00
Alex Dunmow
c825942c8d feat(cli): init auto-commits plugin.mod, drops ninja git remote 2026-06-03 01:34:42 +08:00
Alex Dunmow
e5b27f5a65 feat(cli): rewrite plugin publish to send tar.zst archive 2026-06-03 01:33:12 +08:00
Alex Dunmow
a827cda37a feat(core): mirror PublishVersionRequest archive bytes proto change 2026-06-03 01:27:16 +08:00
Alex Dunmow
31e7b72b49 feat(cli): add BuildSourceArchive for plugin publish tar.zst 2026-06-03 01:21:55 +08:00
Alex Dunmow
680cbe0160 chore(core): add klauspost/compress for plugin archive zstd 2026-06-03 01:19:51 +08:00
Alex Dunmow
e9bef5b065 docs: plan, UAT, and execution prompt for plugin tarball publish + categories 2026-06-03 01:18:13 +08:00
Alex Dunmow
2a76b30c51 feat(cli): scope subcommand, interactive scope prompt, bump+version helpers
Pre-existing CLI improvements ahead of the tarball-publish refactor:
- New top-level `ninja scope` command (create, list, set-default).
- `init` accepts no --scope: prompts from ListMyScopes or uses creds default.
- Plugin name prompted if not provided.
- `plugin bump <major|minor|patch>` writes the bumped version into plugin.mod.
- `plugin version` prints the current plugin.mod version.
- `login` prints a URL with ?user_code= so the link is one click.
- creds: HostCreds gains optional default_scope.
- plugin/version: ParseBaseSemver + BumpVersion helpers, with tests.
2026-06-03 01:18:11 +08:00
Alex Dunmow
1d9ca44f55 docs(spec): plugin publish (tarball) + categories design
Design for two coupled changes: drop git as the publish transport in
favour of tar.zst uploads, and add a first-class plugin kind plus a
configurable, validated category list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 00:58:21 +08:00
Alex Dunmow
32c6528162 feat(templates): add HTMLComponent interface and first-class pongo2 engine
Decouple TemplateFunc from templ.Component by introducing a generic
HTMLComponent interface that both templ and pongo2 satisfy via Go
structural typing. Add a complete pongo2 rendering engine in
templates/pongo/ with page templates, block templates (with BlockContext
injection and icon processing), template overrides, and email wrappers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 23:07:11 +08:00
Alex Dunmow
7f4bce79c9 feat(cli): ninja CLI with login, plugin init/publish/status
Cobra scaffold, credentials store, Connect client, device-flow login,
whoami/logout, plugin init (creates + adds git remote), publish (tag + push
+ registry RPC), and status (list scopes/plugins). Proto copied from
orchestrator with buf codegen for client stubs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 23:55:01 +08:00
Alex Dunmow
7ff326ef25 feat(sdk): ModFile struct, ParseModFull, and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 22:45:26 +08:00
Alex Dunmow
a5caf2d9e7 chore(sdk): add deps for plugin.mod parser and ninja CLI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-01 22:44:55 +08:00
Alex Dunmow
245e38dc95 feat(render): autolink https:// URLs in BlockNote inline text
Plain-text nodes inside paragraphs, headings, lists and table cells now
auto-wrap any https:// URL in <a href="..." rel="noopener">URL</a>.

- Bare domains, www. and http:// URLs are intentionally not linked
- Suppressed inside explicit `link` inline nodes (no nested anchors)
- Suppressed inside `code`-styled spans (URL stays literal)
- Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL
- Closing parens/brackets/braces are kept inside the URL only when
  balanced with an opener (so Wikipedia-style _(bar) is preserved
  but `(see https://x.com)` doesn't eat the trailing paren)
- Bold/italic/color style wrappers compose around the anchor

`renderInlineContent` gains an `insideLink bool` parameter; all existing
call sites pass `false`, and the `link` branch recurses with `true`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 02:01:41 +08:00
Alex Dunmow
7eb3e27053 feat: converge BlockNote renderer, add datasources bridge, rename ServiceDeps to CoreServices
SDK renderer now has full feature parity with the host: text alignment,
checkListItem, toggleListItem, video, audio, file, statement blocks,
and text/background color inline styles. New datasources.Datasources
interface lets plugins resolve buckets directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 10:18:32 +08:00
Alex Dunmow
9c62780246 feat: add context-aware BlockNote SDK bridge 2026-05-03 08:36:08 +08:00
49 changed files with 14405 additions and 51 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "proto"]
path = proto
url = git@git.dev.alexdunmow.com:block/proto.git

9
CLAUDE.md Normal file
View 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
View 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

View File

@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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, ", ")
}

View 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
View 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
View 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
View 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)
},
}
}

View 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
}

View 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
}

View 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
}

View 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)
}
}

View 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
View 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)
}
}

View File

@ -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
} }

View 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"`
}

View File

@ -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.

View File

@ -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 (AE) — 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

File diff suppressed because it is too large Load Diff

View File

@ -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.

View 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
View File

@ -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
View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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)
} }

View 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)
}
}

View File

@ -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
View 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
View 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)
}
}

View File

@ -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
} }

View File

@ -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
View 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

@ -0,0 +1 @@
Subproject commit 9c45bfd5e1da8bd55edcce8d79cbfbd79ca065d1

View File

@ -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:]
}
}

View 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, "&lt;") || !strings.Contains(got, "&gt;") || !strings.Contains(got, "&amp;") {
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"`, `&#34;`},
{"foo https://example.com'", "&#39;"},
}
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 `&amp;` in both href and text
if !strings.Contains(got, `href="https://example.com/?a=1&amp;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
View 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)
}
}

View 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
View 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
View File

@ -0,0 +1,6 @@
package pongo
import "embed"
//go:embed base.html
var baseFS embed.FS

169
templates/pongo/engine.go Normal file
View 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
View 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)
}

View File

@ -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 {