Files
Matt Kane c0ce915c55 feat(plugin-cli): sandboxed plugin authoring CLI (#1057)
* feat(registry-cli): extend manifest schema with identity + trust contract

First phase of the sandboxed plugin redesign (#1028b). Adds the
manifest fields that make `src/index.ts` and the in-code descriptor
factory redundant. The trust contract is now hand-authored in the
manifest, where a security reviewer can find it without grep.

New required fields:

- `slug`: ASCII letter then letters/digits/hyphens/underscores, max 64
  chars. Matches the registry lexicon's rkey grammar via the shared
  PLUGIN_SLUG_RE in @emdash-cms/plugin-types.
- `version`: semver 2.0 subset, no build-metadata (atproto rkeys can't
  contain `+`). Validated via PLUGIN_VERSION_RE.
- `publisher`: now required (was optional in #1028a). The runtime
  cannot compute the plugin's AT URI without it; making it optional
  meant the plugin couldn't load locally before first publish.

New optional fields with sensible defaults:

- `capabilities`: array of capability strings. Defaults to []. Each
  entry validated against the current vocabulary; deprecated names are
  hard-rejected with a hint at the replacement (no deprecation window
  for new authoring).
- `allowedHosts`: array of host patterns. Defaults to []. Required
  non-empty when `network:request` is declared without
  `:unrestricted`. Forbidden when `:unrestricted` is declared.
- `storage`: map of collection name -> { indexes, uniqueIndexes? }.
  Defaults to {}.

The cross-field rule for network:request / allowedHosts mirrors the
release-extension lexicon's networkRequestConstraints behaviour, so
authors hit the schema error here rather than a PDS validation error
at publish time.

Schema regenerated. 33 new tests; 204 total passing.

Part of #1028b. The bundle rewrite, init command, plugin migrations,
and `localPlugin` dev helper land in subsequent commits.

* feat(registry-cli): bundle reads identity + trust contract from manifest

Second phase of the sandboxed plugin redesign (#1028b). Bundle no longer
imports src/index.ts for a descriptor factory; the manifest is the
source of truth for identity (slug, version) and the trust contract
(capabilities, allowedHosts, storage). Bundle still probes the runtime
code for the hook/route surface — that's a syntactic property that
needs the code to exist.

Changes to bundle:

- Drop the main-entry build and descriptor extraction. No more
  src/index.ts probing, no more `createPlugin` / default-factory /
  default-object format detection.
- Replace `resolveEntries`: just locates emdash-plugin.jsonc (loaded
  through the same loader the CLI's validate uses) and confirms
  src/plugin.ts exists. No more package.json `exports` parsing.
- Replace `extractResolvedPlugin` with `assembleResolvedPlugin`: builds
  the ResolvedPlugin shape from the manifest, then probes
  src/plugin.ts for hook/route names.
- Probe (renamed from `augmentWithSandboxProbe` to `probePluginSurface`)
  now reads src/plugin.ts. Hard-fails if the default export isn't a
  definePlugin result.
- New error codes: MISSING_MANIFEST, MISSING_PLUGIN_ENTRY,
  MANIFEST_INVALID. Old MISSING_PACKAGE_JSON / MISSING_ENTRYPOINT /
  MAIN_BUILD_FAILED gone.
- Admin entry handling (admin.js, adminPages, adminWidgets) deferred
  to a follow-up issue. The redesign hasn't touched admin yet; that
  surface stays as-is and is gated on the descriptor's `admin` field
  which no longer exists. When admin lands again it'll be a manifest
  field with its own probe.

Changes to translate.ts:

- `NormalisedManifest` gains slug, version, publisher (required),
  capabilities, allowedHosts, storage. Publisher is no longer
  Optional — the schema enforces it.

Fixtures:

- `minimal-plugin/`: src/index.ts gone, sandbox-entry.ts renamed to
  plugin.ts, new emdash-plugin.jsonc with identity + trust contract.
- `bad-plugin/`: stripped to manifest-only (no src/), exercises
  MISSING_PLUGIN_ENTRY. Old "declares hooks but no sandbox entry"
  case isn't possible anymore — there's no descriptor declaring
  anything.

Net diff: -228 lines.

* feat(registry-cli): init command scaffolds a sandboxed plugin

Third phase of the redesign (#1028b). Adds `emdash-registry init [name]`
which produces the three-file plugin layout introduced by the previous
commits: emdash-plugin.jsonc, src/plugin.ts, package.json, plus a
tsconfig, README, .gitignore, and a passing test.

Modes:
- Interactive (default on a TTY): clack prompts for each unset field
  with sensible defaults. ESC / Ctrl+C cancels cleanly.
- `--yes` / `-y` (non-interactive): no prompts; unset fields become
  TODO placeholders in the manifest. The author fixes them before
  first use.
- Non-TTY (CI, pipes): same as `--yes`; prompting into a non-
  interactive stdin would hang.

Pre-fills:
- Publisher: the active session's handle from FileCredentialStore.
  Resolved through @atcute/identity-resolver to a DID before write
  so the runtime never sees a mutable handle. The handle is emitted
  as a `// <handle>` line comment next to the pinned DID for `git
  diff` readability — same convention as the post-publish write-back.
- Author name / email: `git config user.name` / `user.email`.
- Repo: `git remote get-url origin`, normalised from SSH to https
  (`git@github.com:foo/bar.git` → `https://github.com/foo/bar`).
  Falls back to `package.json#repository.url` if no git remote.
- License, description: `package.json` in the target dir if one
  exists (for the "scaffold into existing repo skeleton" case).

Slug defaults to the positional `name`, `basename(--dir)`, or
basename(cwd) in that order. Every flag is optional in every mode.

Exported `resolveHandleToDid` from manifest/publisher.ts so init
can use the same resolver the post-publish write-back does.

Tests: 44 new (template renderers, scaffold filesystem behaviour,
environment probe). 249 total in the package.

* feat(plugins): migrate in-tree sandboxed plugins to the new layout

Fourth phase of the redesign (#1028b). Moves the 5 in-tree sandboxed
plugins to the manifest + src/plugin.ts shape so they become the
canonical references a plugin author looks at.

Each plugin's layout changes from:

  src/index.ts          (descriptor factory, ~50 lines)
  src/sandbox-entry.ts  (runtime code via definePlugin)
  package.json          (main / exports / files / build scripts)

to:

  emdash-plugin.jsonc   (identity + trust contract + admin surface)
  src/plugin.ts         (runtime code, unchanged)
  package.json          (private, typecheck script only)

Plugins migrated:
- atproto
- audit-log
- marketplace-test
- sandboxed-test
- webhook-notifier

Schema gains `admin` (pages + widgets) since four of the five plugins
declare admin surface. Mirrors PluginAdminPage / PluginDashboardWidget
in core. Atproto's plugin.test.ts rewritten to assert against the
manifest instead of the deleted descriptor factory.

KNOWN BREAKAGE: demos that import the old factories
(`auditLogPlugin()`, `webhookNotifierPlugin()`) from
astro.config.mjs are broken until the next commit ships
`@emdash-cms/registry-cli/dev`'s `localPlugin(dir)` helper and
updates the demos.

All published plugins still work — the bundled manifest.json shape
is unchanged. Only authoring changed.

* feat(registry-cli): add localPlugin(dir) dev helper + wire demos

Final piece of the sandboxed-plugin redesign (#1028b). Closes the gap
the plugin migrations opened — demos that previously imported
`auditLogPlugin()` / `webhookNotifierPlugin()` factories now consume
the plugins through their source directories.

New subpath `@emdash-cms/registry-cli/dev` exports `localPlugin(dir)`,
which:

- Reads `<dir>/emdash-plugin.jsonc` via the same loader the CLI uses.
- Confirms `<dir>/src/plugin.ts` exists.
- Resolves the manifest's publisher (handle → DID) so the descriptor
  is in canonical form.
- Returns a PluginDescriptor-shaped object with `entrypoint` set to
  the absolute `file://` URL of `src/plugin.ts`. Vite resolves the
  URL through its standard fs path resolver — no build step needed.

The descriptor carries id, version, capabilities, allowedHosts,
storage, and (when declared) adminPages + adminWidgets from the
manifest. Plugins that don't expose admin surface pass through
without the optional fields, keeping the descriptor tidy.

Demos updated:
- demos/simple: auditLogPlugin() → localPlugin("../../packages/plugins/audit-log")
- demos/plugins-demo: auditLog + webhookNotifier the same way
- demos/cloudflare: webhookNotifier via localPlugin
- infra/cache-demo, infra/blog-demo: same

Trusted plugins (formsPlugin, embedsPlugin, apiTestPlugin) keep their
factory-based imports — they're not on the new shape and aren't part
of this redesign's scope.

Errors surface as a structured LocalPluginError with codes:
- MANIFEST_INVALID
- PLUGIN_ENTRY_MISSING
- PUBLISHER_UNRESOLVED

Tests: 10 new (descriptor shape, error paths, admin pass-through).
259 total in the package.

* feat(plugin-cli): rework sandboxed plugin authoring, build, and CLI

Renames @emdash-cms/registry-cli to @emdash-cms/plugin-cli and the
binary emdash-registry to emdash-plugin. Adds build + dev commands,
consolidates the build pipeline so bundle is a thin packaging step on
top of build. Introduces a strict author-facing SandboxedPlugin type
via the new emdash/plugin type-only subpath; sandboxed plugins now
default-export a bare { hooks?, routes? } object with satisfies
SandboxedPlugin and have no runtime emdash import. Drops definePlugin
and the build shim for sandboxed plugins (definePlugin is native-only
now). Migrates the five in-tree sandboxed plugins to the new shape.
Manifest version is optional and reconciled with package.json#version.

* fix(plugin-cli): adversarial review fixes

- init scaffold emits the new `satisfies SandboxedPlugin` shape and
  npm-shape package.json (build/dev scripts, ./sandbox export, plugin-cli
  devDep) instead of the broken `definePlugin` template
- publish reads package.json#version and reconciles via normaliseManifest
  so the new "version in package.json only" pattern actually publishes;
  malformed package.json surfaces a CliError, not a misleading
  VERSION_MISSING further down
- dev watcher serialises rebuilds (queue collapsed to one follow-up),
  closes the watcher before draining pending on Ctrl-C, short-circuits
  scheduleRebuild during shutdown, handles Windows path separators in
  the outDir ignore glob, clears pending+queuedTrigger in finally so an
  IIFE rejection can't deadlock the session, and removes SIGINT handlers
  on shutdown
- adapter normalises ctx.request to SandboxedRequest shape in-process
  so handlers see the same { url, method, headers: Record } promised by
  the strict type; null/array/non-object default exports rejected with
  a plugin-id-bearing message
- build's readPackageMeta rejects empty/non-string version with the
  same strictness as publish, killing the build-pass/publish-fail
  asymmetry
- pipeline probe rejects invalid hook config (errorPolicy, priority,
  timeout) so untyped JS authors get a build error rather than a
  silently-wrong runtime contract
- versionless minimal-plugin fixture so bundle/publish/build integration
  tests exercise the package.json-as-source-of-truth path
- definePlugin error wording softened for native-plugin authors whose
  id field has a typo
- pipeline error messages and stale comments updated for the no-shim,
  no-definePlugin authoring shape
- removed dead EMDASH_SHIM from the Cloudflare sandbox runner
- changesets retargeted to @emdash-cms/plugin-cli; scaffold/atproto/core
  comments scrubbed for stale registry-cli references

* style: format

* docs(changesets): switch plugin migration examples to diff fences

* style: format

* Fix changeset ordering

* fix(ci): plugin build uses node-direct path; sweep stale registry-cli refs

In-workspace plugins use `node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build`
because pnpm doesn't create the bin shim for a workspace package whose
bin target doesn't exist at install time. Plugin authors outside the
workspace get a published bin with a real dist, so `emdash-plugin build`
works for them via the natural scaffold.

Also fixes stale registry-cli references the rename pass missed:
- .oxfmtrc.json: schema ignore path
- .oxlintrc.json: 7 type-aware-cost allowlist entries
- .github/workflows/ci.yml: build filter includes plugin-cli for test:unit
- package.json: test:unit script
- packages/plugin-types/package.json: description

The schema file is regenerated to match what gen-schema produces. The
previously committed version had been hand-reformatted post-regen and
disagreed with the generator's output.

* fix(ci): remove legacy marketplace bundle path; address review findings

- Delete `packages/marketplace/tests/publish-e2e.test.ts` — invoked the
  legacy `emdash plugin bundle` from core CLI against the new
  manifest-driven plugin layout, which it doesn't understand.
- Remove the validate-plugins CI job — it used the same legacy CLI
  command. Plugin validation is now covered by `pnpm build`, which
  runs the new `emdash-plugin build` probe + manifest checks against
  every in-tree sandboxed plugin.
- Fix `no-base-to-string` lint errors in audit-log/plugin.ts. The
  canonical ContentHookEvent types `event.content.id` as unknown;
  `String(unknown)` lands on '[object Object]' for record IDs. Added
  a small `stringifyId` helper that returns '' for non-string/number
  inputs so the caller's existence check skips bad rows.
- pipeline.ts now hard-errors when the probed module has no `default`
  export, instead of silently falling through to an empty plugin
  (build had been writing dist/ artifacts with empty hooks/routes for
  any source that used `export const plugin = ...`).
- Scaffold README camelCases hyphenated slugs for the import binding.
  Slugs like `my-plugin` were producing `import my-plugin from ...`
  which is a syntax error. Test added with a hyphenated fixture.

Both bot review comments addressed.

* style: format

* fix(plugin-cli): bump test timeout to 30s for bundle tests on slow CI

bundle.test.ts > 'produces a tarball + manifest for a minimal valid
plugin' timed out at the 5s default on the GitHub-hosted runner.
The test runs the full build pipeline (tsdown probe + transpile +
tarball pack), which is fast locally (<2s) but cold-starts at 5-8s
on CI. Bump to 30s globally for the plugin-cli vitest config.

* chore: update lockfile

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ascorbic <213306+ascorbic@users.noreply.github.com>
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
2026-05-18 15:01:00 +01:00
..