diff --git a/.changeset/busy-rivers-drive.md b/.changeset/busy-rivers-drive.md index b26dc3c51..49c46a79d 100644 --- a/.changeset/busy-rivers-drive.md +++ b/.changeset/busy-rivers-drive.md @@ -1,11 +1,11 @@ --- -"@emdash-cms/registry-cli": minor +"@emdash-cms/plugin-cli": minor --- Adds `emdash-plugin.jsonc` manifest support. Plugin authors can now declare profile fields (license, author, security contact, name, description, keywords, repo) once in a hand-edited JSONC file instead of passing them as flags on every publish. The CLI loads `./emdash-plugin.jsonc` automatically; explicit flags still win for CI use. -New `emdash-registry validate` command checks a manifest against the schema offline with `tsc`-style file:line:column diagnostics. +New `emdash-plugin validate` command checks a manifest against the schema offline with `tsc`-style file:line:column diagnostics. The manifest's optional `publisher` field pins the publishing identity. On first successful publish, the CLI writes the active session's DID back to the manifest. Subsequent publishes verify the active session matches the pinned publisher and refuse on mismatch to prevent accidental cross-account publishes. -JSON Schema for IDE completion ships in the package at `schemas/emdash-plugin.schema.json`; reference it via `"$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json"`. +JSON Schema for IDE completion ships in the package at `schemas/emdash-plugin.schema.json`; reference it via `"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json"`. diff --git a/.changeset/emdash-sandboxed-plugin-authoring.md b/.changeset/emdash-sandboxed-plugin-authoring.md new file mode 100644 index 000000000..71aa5788c --- /dev/null +++ b/.changeset/emdash-sandboxed-plugin-authoring.md @@ -0,0 +1,59 @@ +--- +"emdash": minor +--- + +**BREAKING (plugin authors):** Reworks how sandboxed plugins are defined. The `definePlugin()` helper is removed for sandboxed-format plugins; the new shape is a bare default export with a `satisfies SandboxedPlugin` annotation. A new type-only subpath `emdash/plugin` provides the types. + +This affects anyone _writing_ a sandboxed plugin. Sites that _use_ plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins). + +```diff +- import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash"; ++ import type { SandboxedPlugin } from "emdash/plugin"; + +- export default definePlugin({ ++ export default { + hooks: { + "content:beforeSave": { +- handler: async (event: ContentHookEvent, ctx: PluginContext) => { ++ handler: async (event, ctx) => { + // ... + return event.content; + }, + }, + }, +- }); ++ } satisfies SandboxedPlugin; +``` + +Three changes: + +1. **Drop `import { definePlugin } from "emdash"`** and the `definePlugin(...)` wrapping call. Sandboxed plugins now default-export the bare object. +2. **`import type { SandboxedPlugin } from "emdash/plugin"`** and add `satisfies SandboxedPlugin` to the default export. The `emdash/plugin` subpath is type-only — the bundler erases the import, so no runtime resolution of `emdash` is needed (and the heavy `emdash` runtime no longer enters the plugin bundle). +3. **Drop handler parameter annotations** like `event: ContentSaveEvent, ctx: PluginContext`. The strict mapped type on `SandboxedPlugin` infers them per hook name, with the full canonical event type. If you need to reference an event type by name (e.g. in a helper function), `emdash/plugin` re-exports them: `import type { ContentHookEvent, PluginContext } from "emdash/plugin"`. + +**Why:** the old `definePlugin` was an identity function whose only job was to alias `emdash` to a Proxy shim at build time so the import would resolve. With the new shape, sandboxed plugins have _no_ runtime `emdash` import — only type-only imports from `emdash/plugin`. The bundler doesn't need to alias anything; the build pipeline is simpler; and authors get strict per-hook event/return type inference for free. + +The trade-off: previously you could narrow an event type locally (e.g. `interface ContentSaveEvent { content: ... & { id: string } }`). Under the strict mapped type, the canonical event type wins (TypeScript's contravariance on function parameters means narrowing isn't assignable). Authors validate fields at runtime with `typeof` / `isRecord` checks instead — which is the right pattern for input that comes from outside the type system anyway. + +**Routes** follow the same simplification. The two-arg `(routeCtx, ctx)` shape is unchanged; only the annotations disappear: + +```ts +export default { + routes: { + health: async (routeCtx, ctx) => { + // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. + return new Response("ok"); + }, + }, +} satisfies SandboxedPlugin; +``` + +`SandboxedRouteContext` exposes `{ input, request, requestMeta? }`. `request` is typed as `SandboxedRequest` — a `{ url, method, headers }` record that's portable across in-process and isolate execution (Worker Loader can't pass real `Request` objects across the boundary). + +**Native plugins are unaffected.** This change applies only to sandboxed-format plugins. Native plugins continue to use `definePlugin()` from `emdash` and the existing `PluginDefinition` shape. + +**Type rename:** `SandboxedPlugin` on the `emdash` package now refers to the new author-facing source-shape type. The runtime-side handle type (returned by `SandboxRunner.load`, held in the runtime's plugin cache) is renamed to `SandboxedPluginInstance`. If you import `SandboxedPlugin` from `emdash` to type a sandbox runner implementation or hold runtime plugin handles, update those imports to `SandboxedPluginInstance`. Public consumers of this type are mostly limited to `@emdash-cms/cloudflare` and other sandbox runner adapters; standard plugin / site code is unaffected. + +**Removed types:** `StandardPluginDefinition`, `StandardHookHandler`, `StandardHookEntry`, `StandardRouteHandler`, `StandardRouteEntry` are no longer exported from `emdash`. These were authoring-helper aliases under the old permissive `definePlugin` standard overload. Use `SandboxedPlugin` from `emdash/plugin` for the same purpose under the new shape. + +**Removed function:** `isStandardPluginDefinition` is gone. There's no equivalent — sandboxed plugins are identified by structure (`{ hooks?, routes? }`) and you should treat the default export as already typed via `satisfies SandboxedPlugin`. diff --git a/.changeset/plugin-atproto-default-export.md b/.changeset/plugin-atproto-default-export.md new file mode 100644 index 000000000..f03f092bc --- /dev/null +++ b/.changeset/plugin-atproto-default-export.md @@ -0,0 +1,21 @@ +--- +"@emdash-cms/plugin-atproto": minor +--- + +**BREAKING:** Removes the `atprotoPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. + +```diff +- import { atprotoPlugin } from "@emdash-cms/plugin-atproto"; ++ import atproto from "@emdash-cms/plugin-atproto"; + + export default defineConfig({ + integrations: [ + emdash({ +- sandboxed: [atprotoPlugin()], ++ sandboxed: [atproto], + }), + ], + }); +``` + +Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.changeset/plugin-audit-log-default-export.md b/.changeset/plugin-audit-log-default-export.md new file mode 100644 index 000000000..d7c8d1c2a --- /dev/null +++ b/.changeset/plugin-audit-log-default-export.md @@ -0,0 +1,21 @@ +--- +"@emdash-cms/plugin-audit-log": minor +--- + +**BREAKING:** Removes the `auditLogPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. + +```diff +- import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; ++ import auditLog from "@emdash-cms/plugin-audit-log"; + + export default defineConfig({ + integrations: [ + emdash({ +- plugins: [auditLogPlugin()], ++ plugins: [auditLog], + }), + ], + }); +``` + +Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.changeset/plugin-cli-build-command.md b/.changeset/plugin-cli-build-command.md new file mode 100644 index 000000000..5fcfbcad0 --- /dev/null +++ b/.changeset/plugin-cli-build-command.md @@ -0,0 +1,37 @@ +--- +"@emdash-cms/plugin-cli": minor +--- + +Renames `@emdash-cms/registry-cli` to `@emdash-cms/plugin-cli` and the binary from `emdash-registry` to `emdash-plugin`. The package's job has outgrown the original name — `init`, `build`, `dev`, `bundle`, `publish`, `search`, `info`, `login`, `logout`, `whoami`, and `switch` cover plugin authoring + identity + discovery, not just registry interaction. Adopt the new name on first install; the old package is no longer published. + +This release also adds `emdash-plugin build` and `emdash-plugin dev` and consolidates the build pipeline so `bundle` is a thin packaging step on top of `build`. + +**`emdash-plugin build`** reads `emdash-plugin.jsonc` and `src/plugin.ts`, then emits: + +- `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes (hooks + routes). The same artifact is consumed both in-process (when the plugin is in `plugins: []`) and by the sandbox loader (when in `sandboxed: []`). +- `dist/manifest.json` — wire-shape `PluginManifest` including hooks + routes harvested from probing `src/plugin.ts`. `bundle` packs this verbatim into the registry tarball; on the npm path it's metadata that consumers can read without parsing JSONC. +- `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module that default-exports a bare `PluginDescriptor` object. Emitted only when a sibling `package.json` exists (registry-only plugins skip this, since nothing would import it). + +**`emdash-plugin dev`** watches `src/**`, `emdash-plugin.jsonc`, and `package.json`, debouncing rebuilds at 150ms. On a failed rebuild it leaves the last good `dist/` in place so a downstream site importing the plugin keeps working until the next successful build. Stop with Ctrl-C. + +A typical plugin `package.json`: + +```json +{ + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" + } +} +``` + +**`version` in `emdash-plugin.jsonc` is now optional.** The build reconciles the manifest's `version` with `package.json#version`: + +- Both set and matching → fine. +- Both set and different → hard error. +- One set → that value wins. +- Neither set → hard error. + +The recommended pattern for npm-distributed plugins is to omit `version` from the manifest and let `package.json` be the source of truth. Registry-only plugins (no `package.json`) must set `version` in the manifest. + +**`emdash-plugin bundle`** has been reduced to a packaging step: it now calls `build` to produce `dist/`, validates the bundle contents (no Node-builtin imports, no oversized files, capability sanity), collects optional assets (README, icon, screenshots), and tarballs. Inside the tarball, `plugin.mjs` is renamed to `backend.js` to match the registry's wire-side filename. `validateOnly` still skips tarball creation but now produces the `dist/` artifacts (since "validate" implies "build first"). diff --git a/.changeset/plugin-webhook-notifier-default-export.md b/.changeset/plugin-webhook-notifier-default-export.md new file mode 100644 index 000000000..4733527c2 --- /dev/null +++ b/.changeset/plugin-webhook-notifier-default-export.md @@ -0,0 +1,21 @@ +--- +"@emdash-cms/plugin-webhook-notifier": minor +--- + +**BREAKING:** Removes the `webhookNotifierPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. + +```diff +- import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; ++ import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; + + export default defineConfig({ + integrations: [ + emdash({ +- sandboxed: [webhookNotifierPlugin()], ++ sandboxed: [webhookNotifier], + }), + ], + }); +``` + +Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e190613c5..f0b357bd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,48 +91,15 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - # Build emdash + its deps AND the registry packages. The registry - # packages aren't deps of `emdash`, so the `emdash...` filter would + # Build emdash + its deps AND the plugin-cli + registry packages. + # They aren't deps of `emdash`, so the `emdash...` filter would # leave them unbuilt and their tests would fail to resolve workspace # links to dist/. - - run: pnpm run --filter emdash... --filter "@emdash-cms/registry-*" --filter "@emdash-cms/plugin-types" build + - run: pnpm run --filter emdash... --filter "@emdash-cms/plugin-cli" --filter "@emdash-cms/registry-*" --filter "@emdash-cms/plugin-types" build - run: pnpm test:unit env: EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test - validate-plugins: - name: Validate Plugins - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run --filter emdash... build - - name: Validate marketplace plugins - run: | - CLI="node packages/core/dist/cli/index.mjs" - for dir in packages/plugins/*/; do - [ -f "$dir/package.json" ] || continue - if [ ! -f "$dir/src/sandbox-entry.ts" ] && \ - ! grep -q '"./sandbox"' "$dir/package.json" 2>/dev/null; then - continue - fi - name=$(basename "$dir") - case "$name" in - marketplace-test|sandboxed-test|api-test) continue ;; - esac - echo "::group::Validating $name" - $CLI plugin bundle --validateOnly --dir "$dir" - echo "::endgroup::" - done - test-smoke: name: Smoke Tests runs-on: ubuntu-latest diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b45920654..8beb4ae23 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,6 +8,6 @@ "**/package.json", "**/emdash-env.d.ts", "packages/registry-lexicons/src/generated/**", - "packages/registry-cli/schemas/**" + "packages/plugin-cli/schemas/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index da3b5f8f1..1473bf237 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -68,13 +68,13 @@ "**/client/transport.ts", "**/client/portable-text.ts", "**/cli/**/*.ts", - "packages/registry-cli/src/bundle/api.ts", - "packages/registry-cli/src/bundle/utils.ts", - "packages/registry-cli/src/bundle/command.ts", - "packages/registry-cli/src/bundle/types.ts", - "packages/registry-cli/src/oauth.ts", - "packages/registry-cli/src/publish/api.ts", - "packages/registry-cli/src/commands/publish.ts", + "packages/plugin-cli/src/bundle/api.ts", + "packages/plugin-cli/src/bundle/utils.ts", + "packages/plugin-cli/src/bundle/command.ts", + "packages/plugin-cli/src/bundle/types.ts", + "packages/plugin-cli/src/oauth.ts", + "packages/plugin-cli/src/publish/api.ts", + "packages/plugin-cli/src/commands/publish.ts", "packages/registry-client/src/publishing/index.ts", "**/api/handlers/api-tokens.ts", "**/api/handlers/device-flow.ts", diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 3c7c731cc..fd3434e78 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -11,7 +11,7 @@ import { cloudflareStream, } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; @@ -74,7 +74,7 @@ export default defineConfig({ formsPlugin(), ], // Sandboxed plugins (run in isolated workers) - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], // Sandbox runner for Cloudflare sandboxRunner: sandbox(), // Plugin marketplace diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index f4992a14c..0459fa621 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -18,6 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "astro": "catalog:", @@ -33,7 +34,5 @@ }, "emdash": { "seed": "seed/seed.json" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } diff --git a/demos/plugins-demo/astro.config.mjs b/demos/plugins-demo/astro.config.mjs index d10c4d211..dcf0e34f9 100644 --- a/demos/plugins-demo/astro.config.mjs +++ b/demos/plugins-demo/astro.config.mjs @@ -1,9 +1,9 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; import { apiTestPlugin } from "@emdash-cms/plugin-api-test"; -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; import { embedsPlugin } from "@emdash-cms/plugin-embeds"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { sqlite } from "emdash/db"; @@ -22,18 +22,12 @@ export default defineConfig({ // Register plugins - order matters for hook execution! plugins: [ // 1. Audit log runs last (priority 200) to capture final state - // Settings (retention, data changes, excluded collections) are - // configured at runtime via the admin UI, not constructor options. - auditLogPlugin(), + auditLog, // 2. Webhook notifier sends events to external URLs - // Demonstrates: network:fetch:any, apiRoutes, settings.secret(), - // hook dependencies, errorPolicy: "continue" - // Webhook URL, collections, and actions are configured via admin settings. - webhookNotifierPlugin(), + webhookNotifier, // 3. Embeds plugin for YouTube, Vimeo, Twitter, etc. - // Components are auto-registered with PortableText embedsPlugin(), // 4. API Test plugin - exercises all v2 APIs diff --git a/demos/plugins-demo/package.json b/demos/plugins-demo/package.json index 75b430fe5..628974cee 100644 --- a/demos/plugins-demo/package.json +++ b/demos/plugins-demo/package.json @@ -13,10 +13,11 @@ "dependencies": { "@astrojs/node": "catalog:", "@astrojs/react": "catalog:", - "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-api-test": "workspace:*", - "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-embeds": "workspace:*", + "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "astro": "catalog:", @@ -27,7 +28,5 @@ }, "devDependencies": { "@types/node": "catalog:" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } \ No newline at end of file diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index 662168127..726b23cf5 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -1,6 +1,6 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; import { defineConfig, fontProviders } from "astro/config"; import emdash, { local } from "emdash/astro"; import { sqlite } from "emdash/db"; @@ -22,7 +22,7 @@ export default defineConfig({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ], fonts: [ diff --git a/demos/simple/package.json b/demos/simple/package.json index a3cf8210c..38c335bfa 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -20,6 +20,7 @@ "@emdash-cms/plugin-atproto": "workspace:*", "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-color": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "astro": "catalog:", "better-sqlite3": "catalog:", "emdash": "workspace:*", @@ -28,7 +29,5 @@ }, "devDependencies": { "@astrojs/check": "catalog:" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } diff --git a/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx b/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx index 783e3ca4d..e28352ed5 100644 --- a/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx @@ -172,18 +172,18 @@ Things worth knowing: ## Register the plugin -In your site's `astro.config.mjs`, import the descriptor factory and pass it into the EmDash integration. Sandboxed plugins go in `sandboxed: []`; in-process plugins go in `plugins: []`. A standard-format plugin works in both — start with `sandboxed`. +In your site's `astro.config.mjs`, import the default-exported descriptor and pass it into the EmDash integration. Sandboxed plugins go in `sandboxed: []`; in-process plugins go in `plugins: []`. A standard-format plugin works in both — start with `sandboxed`. ```typescript title="astro.config.mjs" import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { sandbox } from "@emdash-cms/cloudflare"; -import { helloPlugin } from "@my-org/plugin-hello"; +import hello from "@my-org/plugin-hello"; export default defineConfig({ integrations: [ emdash({ - sandboxed: [helloPlugin()], + sandboxed: [hello], sandboxRunner: sandbox(), }), ], diff --git a/infra/blog-demo/astro.config.mjs b/infra/blog-demo/astro.config.mjs index 00e20e7e8..7476714bc 100644 --- a/infra/blog-demo/astro.config.mjs +++ b/infra/blog-demo/astro.config.mjs @@ -2,7 +2,7 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; @@ -19,7 +19,7 @@ export default defineConfig({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [formsPlugin()], - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], sandboxRunner: sandbox(), experimental: { registry: "https://registry.emdashcms.com", diff --git a/infra/blog-demo/package.json b/infra/blog-demo/package.json index 4499c2e33..9fdb7b536 100644 --- a/infra/blog-demo/package.json +++ b/infra/blog-demo/package.json @@ -18,6 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "astro": "catalog:", "emdash": "workspace:*", "react": "catalog:", diff --git a/infra/cache-demo/astro.config.mjs b/infra/cache-demo/astro.config.mjs index 519ea9e80..3e2ffa53a 100644 --- a/infra/cache-demo/astro.config.mjs +++ b/infra/cache-demo/astro.config.mjs @@ -3,7 +3,7 @@ import { cacheCloudflare } from "@astrojs/cloudflare/cache"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; @@ -25,7 +25,7 @@ export default defineConfig({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [formsPlugin()], - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], sandboxRunner: sandbox(), marketplace: "https://marketplace.emdashcms.com", }), diff --git a/infra/cache-demo/package.json b/infra/cache-demo/package.json index a641e3e7c..1575e0a18 100644 --- a/infra/cache-demo/package.json +++ b/infra/cache-demo/package.json @@ -18,6 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "astro": "https://pkg.pr.new/astro@94d342d", "emdash": "workspace:*", "react": "catalog:", diff --git a/package.json b/package.json index 9e11a34fb..9f6655c70 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck:templates": "pnpm run --workspace-concurrency=1 --filter {./templates/*} typecheck", "check": "pnpm run typecheck && pnpm run --filter {./packages/*} check", "test": "pnpm run --filter {./packages/*} test", - "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-cli --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", + "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", "test:browser": "pnpm run --filter @emdash-cms/admin test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", @@ -52,7 +52,7 @@ "prettier-plugin-astro": "^0.14.1", "typescript": "6.0.0-beta" }, - "packageManager": "pnpm@11.1.2", + "packageManager": "pnpm@10.28.0", "engines": { "node": ">=22" } diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 49cf471a5..530797aea 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -318,7 +318,7 @@ export async function listRegistryReleases( /** * Resolve a publisher DID to its claimed handle using the same - * `LocalActorResolver` pattern as `@emdash-cms/registry-cli` and + * `LocalActorResolver` pattern as `@emdash-cms/plugin-cli` and * `@emdash-cms/auth-atproto`. Bidirectional verification (handle's * domain points back to the same DID) is part of the resolver -- * `LocalActorResolver` returns the sentinel `"handle.invalid"` when diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index 1ac0f7ea2..fcf45df60 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -15,7 +15,7 @@ import { env, exports } from "cloudflare:workers"; import { normalizeCapabilities, type SandboxRunner, - type SandboxedPlugin, + type SandboxedPluginInstance, type SandboxEmailSendCallback, type SandboxOptions, type SandboxRunnerFactory, @@ -27,8 +27,6 @@ import { setEmailSendCallback } from "./bridge.js"; import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js"; import { generatePluginWrapper } from "./wrapper.js"; -const EMDASH_SHIM = "export const definePlugin = (d) => d;\n"; - /** * Default resource limits for sandboxed plugins. * @@ -133,7 +131,7 @@ export class CloudflareSandboxRunner implements SandboxRunner { * @param manifest - Plugin manifest with capabilities and storage declarations * @param code - The bundled plugin JavaScript code */ - async load(manifest: PluginManifest, code: string): Promise { + async load(manifest: PluginManifest, code: string): Promise { const pluginId = `${manifest.id}:${manifest.version}`; // Return cached plugin if available @@ -186,7 +184,7 @@ export class CloudflareSandboxRunner implements SandboxRunner { * We must create fresh stubs for each invocation to avoid I/O isolation errors: * "Cannot perform I/O on behalf of a different request" */ -class CloudflareSandboxedPlugin implements SandboxedPlugin { +class CloudflareSandboxedPlugin implements SandboxedPluginInstance { readonly id: string; readonly manifest: PluginManifest; private loader: WorkerLoader; @@ -262,7 +260,6 @@ class CloudflareSandboxedPlugin implements SandboxedPlugin { modules: { "plugin.js": { js: this.wrapperCode! }, "sandbox-plugin.js": { js: this.code }, - emdash: { js: EMDASH_SHIM }, }, // Block direct network access - plugins must use ctx.http via bridge globalOutbound: null, diff --git a/packages/core/package.json b/packages/core/package.json index fbe5ce6db..f0cc36dfb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,6 +22,9 @@ "types": "./dist/astro/index.d.mts", "default": "./dist/astro/index.mjs" }, + "./plugin": { + "types": "./dist/plugin-types.d.mts" + }, "./middleware": { "types": "./dist/astro/middleware.d.mts", "default": "./dist/astro/middleware.mjs" diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index dec415532..a8e9ac98f 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -126,7 +126,7 @@ const MULTIHASH_SHA256_LENGTH = 0x20; /** * Compute the multibase-multihash sha2-256 checksum of `bytes`, in the * same `b` shape the registry CLI publishes - * (`packages/registry-cli/src/multihash.ts`). Returns a 56-character + * (`packages/plugin-cli/src/multihash.ts`). Returns a 56-character * string starting with `b`. * * The trust contract is: if both sides produce the same string for @@ -157,7 +157,7 @@ async function sha256MultibaseMultihash(bytes: Uint8Array): Promise { * publishers / tools that emit hex rather than multibase. * - Multibase-multihash with the `b` (base32) prefix and sha2-256. * This is the format RFC 0001 mandates and the registry CLI emits - * (see `packages/registry-cli/src/multihash.ts`). + * (see `packages/plugin-cli/src/multihash.ts`). * * Hash functions other than sha2-256 are out of scope for this * initial release; the install fails closed. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index dc4ace5c9..737f9bcdb 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -28,7 +28,7 @@ import type { ContentItem as ContentItemInternal } from "./database/repositories import { validateIdentifier } from "./database/validate.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; -import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js"; +import type { SandboxedPluginInstance, SandboxRunner } from "./plugins/sandbox/types.js"; import type { ResolvedPlugin, MediaItem, @@ -271,7 +271,7 @@ export interface EmDashRuntimeParts { db: Kysely; storage: Storage | null; configuredPlugins: ResolvedPlugin[]; - sandboxedPlugins: Map; + sandboxedPlugins: Map; sandboxedPluginEntries: SandboxedPluginEntry[]; hooks: HookPipeline; enabledPlugins: Set; @@ -304,7 +304,7 @@ function contentItemToRecord(item: ContentItemInternal): Record const dbCache = new Map>(); let dbInitPromise: Promise> | null = null; const storageCache = new Map(); -const sandboxedPluginCache = new Map(); +const sandboxedPluginCache = new Map(); /** * Per-tier sets of `${pluginId}:${version}` keys present in * `sandboxedPluginCache`. Used during sync to know which entries belong @@ -342,7 +342,7 @@ export class EmDashRuntime { private readonly _db: Kysely; readonly storage: Storage | null; readonly configuredPlugins: ResolvedPlugin[]; - readonly sandboxedPlugins: Map; + readonly sandboxedPlugins: Map; readonly sandboxedPluginEntries: SandboxedPluginEntry[]; readonly schemaRegistry: SchemaRegistry; private _hooks!: HookPipeline; @@ -1121,7 +1121,7 @@ export class EmDashRuntime { private static async loadSandboxedPlugins( deps: RuntimeDependencies, db: Kysely, - ): Promise> { + ): Promise> { // Return cached plugins if already loaded if (sandboxedPluginCache.size > 0) { return sandboxedPluginCache; @@ -1199,7 +1199,7 @@ export class EmDashRuntime { db: Kysely, storage: Storage, deps: RuntimeDependencies, - cache: Map, + cache: Map, ): Promise { // Ensure sandbox runner exists if (!sandboxRunner && deps.createSandboxRunner) { @@ -2352,7 +2352,7 @@ export class EmDashRuntime { // Sandboxed Plugin Helpers // ========================================================================= - private findSandboxedPlugin(pluginId: string): SandboxedPlugin | undefined { + private findSandboxedPlugin(pluginId: string): SandboxedPluginInstance | undefined { for (const [key, plugin] of this.sandboxedPlugins) { if (key.startsWith(pluginId + ":")) { return plugin; @@ -2565,7 +2565,7 @@ export class EmDashRuntime { } private async handleSandboxedRoute( - plugin: SandboxedPlugin, + plugin: SandboxedPluginInstance, path: string, request: Request, ): Promise<{ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dfa8ce9c7..e9624cdff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -188,7 +188,6 @@ export { EmDashStorageError } from "./storage/types.js"; export { definePlugin, adaptSandboxEntry, - isStandardPluginDefinition, pluginManifestSchema, createHookPipeline, HookPipeline, @@ -243,16 +242,9 @@ export type { CollectionCommentSettings, StoredComment, - // Standard plugin format - StandardPluginDefinition, - StandardHookHandler, - StandardHookEntry, - StandardRouteHandler, - StandardRouteEntry, - - // Sandbox types + // Sandbox runtime types SandboxRunner, - SandboxedPlugin, + SandboxedPluginInstance, SandboxRunnerFactory, SandboxOptions, SandboxEmailMessage, diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts new file mode 100644 index 000000000..506e83230 --- /dev/null +++ b/packages/core/src/plugin-types.ts @@ -0,0 +1,240 @@ +/** + * `emdash/plugin` — types for authoring sandboxed plugins. + * + * This is a **type-only** subpath. The package.json export map only + * declares a `types` condition, so the bundler erases `import type` + * statements against this entry and the build never tries to resolve a + * JavaScript module. That's how a sandboxed plugin can import these + * types without dragging the `emdash` runtime into its bundle. + * + * Recommended authoring pattern: + * + * ```ts + * import type { SandboxedPlugin } from "emdash/plugin"; + * + * export default { + * hooks: { + * "content:beforeSave": async (event, ctx) => { + * // event: ContentHookEvent, ctx: PluginContext — both inferred. + * return event.content; + * }, + * }, + * routes: { + * health: async (routeCtx, ctx) => ({ ok: true }), + * }, + * } satisfies SandboxedPlugin; + * ``` + * + * The `satisfies SandboxedPlugin` annotation drives full inference on + * every hook handler. Authors should not need to annotate handler + * params — TypeScript reads the event type from the hook name. The + * runtime probe at build time reads `default.hooks` and `default.routes` + * directly; the shape declared here mirrors what the probe consumes. + * + * Return types matter: `content:beforeSave` may return a mutated + * `content` to override the saved fields; `content:beforeDelete` and + * `comment:beforeCreate` may return `false` to veto; `page:metadata` + * returns the metadata contribution. The mapped type captures these + * per-hook return contracts so misuse fails at compile time. + */ + +import type { + CommentAfterCreateEvent, + CommentAfterCreateHandler, + CommentAfterModerateEvent, + CommentAfterModerateHandler, + CommentBeforeCreateEvent, + CommentBeforeCreateHandler, + CommentModerateEvent, + CommentModerateHandler, + ContentAfterDeleteHandler, + ContentAfterPublishHandler, + ContentAfterSaveHandler, + ContentAfterUnpublishHandler, + ContentBeforeDeleteHandler, + ContentBeforeSaveHandler, + ContentDeleteEvent, + ContentHookEvent, + ContentPublishStateChangeEvent, + CronEvent, + CronHandler, + EmailAfterSendEvent, + EmailAfterSendHandler, + EmailBeforeSendEvent, + EmailBeforeSendHandler, + EmailDeliverEvent, + EmailDeliverHandler, + LifecycleEvent, + LifecycleHandler, + MediaAfterUploadEvent, + MediaAfterUploadHandler, + MediaBeforeUploadHandler, + MediaUploadEvent, + PageFragmentEvent, + PageFragmentHandler, + PageMetadataEvent, + PageMetadataHandler, + PluginContext, + UninstallEvent, + UninstallHandler, +} from "./plugins/types.js"; + +/** + * Map from hook name to its handler signature. Adding or changing a + * hook signature in the runtime means updating this map; the rest of + * the type story flows from it. Authors writing + * `"content:beforeSave": async (event, ctx) => { ... }` get `event` + * typed as `ContentHookEvent` and `ctx` as `PluginContext` for free. + */ +export interface HookHandlers { + "plugin:install": LifecycleHandler; + "plugin:activate": LifecycleHandler; + "plugin:deactivate": LifecycleHandler; + "plugin:uninstall": UninstallHandler; + "content:beforeSave": ContentBeforeSaveHandler; + "content:afterSave": ContentAfterSaveHandler; + "content:beforeDelete": ContentBeforeDeleteHandler; + "content:afterDelete": ContentAfterDeleteHandler; + "content:afterPublish": ContentAfterPublishHandler; + "content:afterUnpublish": ContentAfterUnpublishHandler; + "media:beforeUpload": MediaBeforeUploadHandler; + "media:afterUpload": MediaAfterUploadHandler; + cron: CronHandler; + "email:beforeSend": EmailBeforeSendHandler; + "email:deliver": EmailDeliverHandler; + "email:afterSend": EmailAfterSendHandler; + "comment:beforeCreate": CommentBeforeCreateHandler; + "comment:moderate": CommentModerateHandler; + "comment:afterCreate": CommentAfterCreateHandler; + "comment:afterModerate": CommentAfterModerateHandler; + "page:metadata": PageMetadataHandler; + "page:fragments": PageFragmentHandler; +} + +/** + * Hook-handler config form. The bare-function form is also accepted + * (see `HookEntry`) — this is the long form that lets authors override + * priority, timeout, exclusivity. `errorPolicy` and `dependencies` are + * read by the host but rarely set by authors. + */ +export interface HookConfig { + handler: HookHandlers[K]; + priority?: number; + timeout?: number; + dependencies?: string[]; + errorPolicy?: "continue" | "abort"; + exclusive?: boolean; +} + +/** + * Either a bare handler or the config form. The build probe accepts + * both shapes and the runtime normalises to the config form before + * dispatch. + */ +export type HookEntry = HookHandlers[K] | HookConfig; + +/** + * Request fields a route handler can rely on across both trusted and + * sandboxed execution. Trusted handlers receive a real `Request` + * (which is structurally compatible — has `url`, `method`, `headers`); + * sandboxed handlers receive a serialised `{ url, method, headers }` + * record because Worker Loader can't pass `Request` objects across + * the boundary. The shared shape is what's actually portable. + * + * `headers` is intentionally `Record` rather than + * `Headers` so the sandboxed serialised form (which is a plain + * record) typechecks. Trusted handlers receiving a real `Headers` + * object can still call `.get(...)`, but reading via this type's + * indexing requires the lookup to be lowercased and exact. Authors + * iterating headers in a portable way should use `Object.entries`. + */ +export interface SandboxedRequest { + url: string; + method: string; + headers: Record; +} + +/** + * Context passed to a route handler. Routes get an extra `routeCtx` + * argument with the call-site input + the originating request, in + * addition to the standard `PluginContext`. + * + * `input` is `unknown` because plugins validate it themselves — no + * central schema for route payloads. + */ +export interface SandboxedRouteContext { + input: unknown; + request: SandboxedRequest; + requestMeta?: unknown; +} + +/** + * Route handler. The two-arg shape (`routeCtx`, `pluginCtx`) matches + * how the standard-format runtime invokes routes — distinct from + * native plugins, where routes take a single context with the input + * merged in. + * + * Return type is `unknown` because routes serialise their return value + * to JSON for the caller; authors define their own response shape. + */ +export type RouteHandler = ( + routeCtx: SandboxedRouteContext, + ctx: PluginContext, +) => Promise; + +/** + * Route entry — either a bare handler or the config form with + * `public`, `input` schema, and so on. The build probe accepts both. + */ +export type RouteEntry = + | RouteHandler + | { + handler: RouteHandler; + public?: boolean; + input?: unknown; + }; + +/** + * The shape of a sandboxed plugin's default export. + * + * Both `hooks` and `routes` are optional — a plugin that only declares + * one is valid. Hook keys are constrained to the runtime's hook + * vocabulary so a typo (`"content:beforSave"`) is a compile error. + * Route keys are open because route names are author-chosen URL path + * segments. + */ +export interface SandboxedPlugin { + hooks?: { + [K in keyof HookHandlers]?: HookEntry; + }; + routes?: Record; +} + +/** + * Re-export of event types so plugin authors can reference them + * explicitly when needed (helper functions, type predicates). Most + * authors won't need these — the mapped type infers them at handler + * call sites. But the default-export's inferred type also needs them + * publicly nameable so `satisfies SandboxedPlugin` can produce a + * portable `.d.mts`. + */ +export type { + CommentAfterCreateEvent, + CommentAfterModerateEvent, + CommentBeforeCreateEvent, + CommentModerateEvent, + ContentDeleteEvent, + ContentHookEvent, + ContentPublishStateChangeEvent, + CronEvent, + EmailAfterSendEvent, + EmailBeforeSendEvent, + EmailDeliverEvent, + LifecycleEvent, + MediaAfterUploadEvent, + MediaUploadEvent, + PageFragmentEvent, + PageMetadataEvent, + PluginContext, + UninstallEvent, +}; diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 8535febd8..0c952c000 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -11,12 +11,10 @@ */ import type { PluginDescriptor } from "../astro/integration/runtime.js"; +import type { SandboxedPlugin } from "../plugin-types.js"; import { PLUGIN_CAPABILITIES, HOOK_NAMES } from "./manifest-schema.js"; import { normalizeCapabilities } from "./types.js"; import type { - StandardPluginDefinition, - StandardHookEntry, - StandardHookHandler, ResolvedPlugin, ResolvedPluginHooks, ResolvedHook, @@ -26,6 +24,29 @@ import type { PluginAdminConfig, } from "./types.js"; +/** + * Loose per-hook entry shape used inside the adapter's iteration loop. + * + * `SandboxedPlugin.hooks` is a mapped type keyed by hook name, so each + * entry's type depends on the key. When the adapter iterates with + * `Object.entries`, the key is `string` (TypeScript can't see the + * narrowing), so we need a *union* type that covers every hook entry + * shape — bare handler or config form. This is that union, kept local + * because it has no use outside the adapter. + */ +// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event types across all hook names +type AnyHookHandler = (...args: any[]) => Promise; +type AnyHookEntry = + | AnyHookHandler + | { + handler: AnyHookHandler; + priority?: number; + timeout?: number; + dependencies?: string[]; + errorPolicy?: "continue" | "abort"; + exclusive?: boolean; + }; + /** * Default hook configuration values */ @@ -34,27 +55,23 @@ const DEFAULT_TIMEOUT = 5000; const DEFAULT_ERROR_POLICY = "abort" as const; /** - * Check if a standard hook entry is a config object (has a `handler` property) + * Check if a hook entry is the config form (has a `handler` property). */ -function isHookConfig( - entry: StandardHookEntry, -): entry is Exclude { +function isHookConfig(entry: AnyHookEntry): entry is Exclude { return typeof entry === "object" && entry !== null && "handler" in entry; } /** - * Resolve a single standard hook entry to a ResolvedHook. + * Resolve a single hook entry to a ResolvedHook. * - * Standard-format hooks use the sandbox entry convention: - * handler(event, ctx) -- two args + * Sandboxed-format hooks use the standard two-arg convention: + * handler(event, ctx) * * The HookPipeline dispatch methods also call handlers with (event, ctx), - * so the handler is compatible as-is. We just need to wrap it for type safety. + * so the handler is compatible as-is — we just normalise the + * surrounding config (priority, timeout, etc.) to its defaults. */ -function resolveStandardHook( - entry: StandardHookEntry, - pluginId: string, -): ResolvedHook { +function resolveSandboxedHook(entry: AnyHookEntry, pluginId: string): ResolvedHook { if (isHookConfig(entry)) { return { priority: entry.priority ?? DEFAULT_PRIORITY, @@ -84,27 +101,46 @@ const VALID_CAPABILITIES_SET = new Set(PLUGIN_CAPABILITIES); const VALID_HOOK_NAMES_SET = new Set(HOOK_NAMES); /** - * Adapt a standard-format plugin definition into a ResolvedPlugin. + * Adapt a sandboxed plugin's default export into a ResolvedPlugin. * - * This is the core of the unified plugin format. It takes the `{ hooks, routes }` - * export from a standard plugin and produces a ResolvedPlugin that can enter the - * HookPipeline alongside native plugins. + * This is the in-process side of sandboxed-format plugins: it takes + * the `{ hooks, routes }` default export of a sandboxed plugin and + * produces a `ResolvedPlugin` that enters the HookPipeline alongside + * native plugins. The descriptor supplies identity (id, version) and + * the trust contract (capabilities, allowedHosts, storage); the + * definition supplies behaviour. * - * @param definition - The standard plugin definition (from definePlugin() or raw export) + * @param definition - The plugin's default export (matching `SandboxedPlugin` from `emdash/plugin`). * @param descriptor - The plugin descriptor with id, version, capabilities, etc. - * @returns A ResolvedPlugin compatible with HookPipeline + * @returns A ResolvedPlugin compatible with HookPipeline. */ export function adaptSandboxEntry( - definition: StandardPluginDefinition, + definition: SandboxedPlugin, descriptor: PluginDescriptor, ): ResolvedPlugin { const pluginId = descriptor.id; const version = descriptor.version; - // Resolve hooks + // A null / array / non-object `definition` would throw a generic + // `TypeError: Cannot read properties of null` further down the + // loop without the plugin id; surface a useful error first. + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw new Error( + `Plugin "${pluginId}" default export must be an object with ` + + `\`hooks\` and/or \`routes\` (got ${ + Array.isArray(definition) ? "array" : typeof definition + }). Did you forget \`export default {...} satisfies SandboxedPlugin\`?`, + ); + } + + // Resolve hooks. `SandboxedPlugin.hooks` is keyed by hook name with + // per-key entry types; iterating with `Object.entries` collapses + // keys to `string`, so we treat each entry as the union `AnyHookEntry` + // for the duration of the loop. const resolvedHooks: ResolvedPluginHooks = {}; if (definition.hooks) { - for (const [hookName, entry] of Object.entries(definition.hooks)) { + const hookMap = definition.hooks as Record; + for (const [hookName, entry] of Object.entries(hookMap)) { if (!VALID_HOOK_NAMES_SET.has(hookName)) { throw new Error( `Plugin "${pluginId}" declares unknown hook "${hookName}". ` + @@ -115,33 +151,73 @@ export function adaptSandboxEntry( // We store it as the generic type and let HookPipeline's typed dispatch // methods handle the type narrowing at call time. // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- bridging untyped map to typed interface - (resolvedHooks as Record)[hookName] = resolveStandardHook(entry, pluginId); + (resolvedHooks as Record)[hookName] = resolveSandboxedHook(entry, pluginId); } } - // Resolve routes: standard format uses (routeCtx, pluginCtx) two-arg pattern. - // Native format uses (ctx: RouteContext) single-arg pattern where RouteContext - // extends PluginContext with { input, request, requestMeta }. - // We wrap standard route handlers to merge the two args into one. + // Resolve routes: sandboxed format uses (routeCtx, pluginCtx) two-arg + // pattern. Native format uses (ctx: RouteContext) single-arg pattern + // where RouteContext extends PluginContext with + // { input, request, requestMeta }. We wrap sandboxed route handlers + // to merge the two args into one. + // + // Route entries can be bare functions or `{ handler, public?, input? }` + // config objects; normalise to the config shape inside the loop. const resolvedRoutes: Record = {}; if (definition.routes) { - for (const [routeName, routeEntry] of Object.entries(definition.routes)) { - const standardHandler = routeEntry.handler; + for (const [routeName, rawEntry] of Object.entries(definition.routes)) { + const isConfig = typeof rawEntry === "object" && rawEntry !== null && "handler" in rawEntry; + const handler = isConfig + ? (rawEntry as { handler: (...args: unknown[]) => Promise }).handler + : (rawEntry as (...args: unknown[]) => Promise); + const publicFlag = isConfig ? (rawEntry as { public?: boolean }).public : undefined; + const inputSchema = isConfig ? (rawEntry as { input?: unknown }).input : undefined; resolvedRoutes[routeName] = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- StandardRouteEntry.input is intentionally loosely typed; callers validate at runtime - input: routeEntry.input as PluginRoute["input"], - public: routeEntry.public, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- route entry.input is intentionally loosely typed; callers validate at runtime + input: inputSchema as PluginRoute["input"], + public: publicFlag, handler: async (ctx) => { - // Build the routeCtx shape that standard handlers expect + // In-process, `ctx.request` is a real WHATWG `Request` + // with a `Headers` object. The author-facing + // `SandboxedRequest` type promises a plain + // `Record` (the shape the sandbox's + // serialised form delivers). Normalise so handlers + // behave the same in-process and in-isolate. + const headers: Record = {}; + if (ctx.request && typeof ctx.request === "object") { + const h: unknown = (ctx.request as { headers?: unknown }).headers; + if (h && typeof h === "object") { + if (typeof (h as Headers).forEach === "function") { + (h as Headers).forEach((value, name) => { + headers[name] = value; + }); + } else { + for (const [name, value] of Object.entries(h as Record)) { + headers[name] = value; + } + } + } + } + const requestShape = { + url: + (ctx.request as { url?: unknown } | undefined)?.url && + typeof (ctx.request as { url: unknown }).url === "string" + ? (ctx.request as { url: string }).url + : "", + method: + (ctx.request as { method?: unknown } | undefined)?.method && + typeof (ctx.request as { method: unknown }).method === "string" + ? (ctx.request as { method: string }).method + : "GET", + headers, + }; const routeCtx = { input: ctx.input, - request: ctx.request, + request: requestShape, requestMeta: ctx.requestMeta, }; - // Pass only the PluginContext portion (without input/request/requestMeta) - // to match what sandboxed handlers receive. const { input: _, request: __, requestMeta: ___, ...pluginCtx } = ctx; - return standardHandler(routeCtx, pluginCtx); + return handler(routeCtx, pluginCtx); }, }; } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 308647215..d5bf14ac9 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -1,16 +1,13 @@ /** * definePlugin() Helper * - * Creates a properly typed and normalized plugin definition. - * Supports two formats: - * - * 1. **Native format** -- full PluginDefinition with id, version, capabilities, etc. - * Returns a ResolvedPlugin. - * - * 2. **Standard format** -- just { hooks, routes }. No id/version/capabilities. - * Returns the same object (identity function for type inference). - * Metadata comes from the descriptor at config time. + * Native plugin authoring entry. Returns a fully-resolved + * `ResolvedPlugin` ready for the host integration to mount. * + * Sandboxed plugins do NOT use this function. They default-export + * a bare `{ hooks?, routes? }` object with a `satisfies SandboxedPlugin` + * annotation from `emdash/plugin`. See the `emdash` changeset for the + * authoring shape. */ import { normalizeCapabilities } from "./types.js"; @@ -23,7 +20,6 @@ import type { HookConfig, PluginCapability, PluginStorageConfig, - StandardPluginDefinition, } from "./types.js"; // Plugin ID validation patterns @@ -32,33 +28,13 @@ const SCOPED_ID = /^@[a-z0-9-]+\/[a-z0-9-]+$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; /** - * Define an EmDash plugin. - * - * **Standard format** -- the canonical format for plugins that work in both - * trusted and sandboxed modes. No id/version -- those come from the descriptor. - * - * @example - * ```typescript - * import { definePlugin } from "emdash"; - * - * export default definePlugin({ - * hooks: { - * "content:afterSave": { - * handler: async (event, ctx) => { - * await ctx.kv.set("lastSave", Date.now()); - * }, - * }, - * }, - * routes: { - * status: { - * handler: async (routeCtx, ctx) => ({ ok: true }), - * }, - * }, - * }); - * ``` + * Define a native EmDash plugin. * - * **Native format** -- for plugins that need React admin, direct DB access, - * or other capabilities not available in the sandbox. + * Native plugins ship as regular npm modules, get installed via + * `pnpm add` + an `astro.config.mjs` edit, and run in the host + * process. They have full access to the runtime — capabilities are + * still enforced by `PluginContextFactory`, but there is no isolation + * boundary. * * @example * ```typescript @@ -83,30 +59,32 @@ const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; * } * }); * ``` + * + * Sandboxed-format plugins do not use `definePlugin`. They + * default-export a bare `{ hooks?, routes? }` object with a + * `satisfies SandboxedPlugin` annotation from `emdash/plugin`. Calling + * `definePlugin` with an object that has no `id` throws at runtime + * (the type system already rejects it at compile time — this check is + * for callers that bypass typechecking). */ -// Native overload first -- PluginDefinition (with id+version) is more specific export function definePlugin( definition: PluginDefinition, -): ResolvedPlugin; -// Standard overload second -- catches { hooks, routes } without id/version -export function definePlugin(definition: StandardPluginDefinition): StandardPluginDefinition; -export function definePlugin( - definition: PluginDefinition | StandardPluginDefinition, -): ResolvedPlugin | StandardPluginDefinition { - // Standard format: has hooks/routes but no id/version - if (!("id" in definition) || !("version" in definition)) { - // Validate that the standard format has at least hooks or routes - if (!("hooks" in definition) && !("routes" in definition)) { - throw new Error( - "Standard plugin format requires at least `hooks` or `routes`. " + - "For native format, provide `id` and `version`.", - ); - } - // Identity function -- return as-is for type inference. - // The adapter (adaptSandboxEntry) will convert this to a ResolvedPlugin at build time. - return definition; +): ResolvedPlugin { + // Semantic check, not a structural one: `id` is what makes this a + // native definition. Sandboxed plugins (the only other shape that + // might land here at runtime) intentionally never have an `id` — + // identity comes from the manifest's `slug` + `publisher`, computed + // at install time. So "no id" is the unambiguous signal that the + // caller meant the sandboxed authoring flow. + if (typeof definition.id !== "string" || definition.id.length === 0) { + throw new Error( + `definePlugin() requires \`id\` (got ${typeof definition.id}). ` + + "For native plugins, make sure your definition has both `id` and " + + "`version`. For sandboxed plugins, drop `definePlugin()` entirely " + + "and `export default { hooks, routes } satisfies SandboxedPlugin` " + + 'from "emdash/plugin" — identity comes from `emdash-plugin.jsonc`.', + ); } - return defineNativePlugin(definition); } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 0b015f9f9..abc92c5b4 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -71,7 +71,7 @@ export { } from "./sandbox/index.js"; export type { SandboxRunner, - SandboxedPlugin, + SandboxedPluginInstance, SandboxRunnerFactory, SandboxOptions, SandboxEmailMessage, @@ -183,15 +183,7 @@ export type { PluginDefinition, ResolvedPlugin, PluginManifest, - - // Standard plugin format - StandardPluginDefinition, - StandardHookHandler, - StandardHookEntry, - StandardRouteHandler, - StandardRouteEntry, } from "./types.js"; -export { isStandardPluginDefinition } from "./types.js"; // Capability normalization (legacy → canonical alias layer) export { diff --git a/packages/core/src/plugins/sandbox/index.ts b/packages/core/src/plugins/sandbox/index.ts index ae3050ca8..d52df6648 100644 --- a/packages/core/src/plugins/sandbox/index.ts +++ b/packages/core/src/plugins/sandbox/index.ts @@ -7,7 +7,7 @@ export { NoopSandboxRunner, SandboxNotAvailableError, createNoopSandboxRunner } export type { SandboxRunner, - SandboxedPlugin, + SandboxedPluginInstance, SandboxRunnerFactory, SandboxOptions, SandboxEmailMessage, diff --git a/packages/core/src/plugins/sandbox/noop.ts b/packages/core/src/plugins/sandbox/noop.ts index f9369eb73..1899d89b5 100644 --- a/packages/core/src/plugins/sandbox/noop.ts +++ b/packages/core/src/plugins/sandbox/noop.ts @@ -7,7 +7,7 @@ */ import type { PluginManifest } from "../types.js"; -import type { SandboxRunner, SandboxedPlugin, SandboxOptions } from "./types.js"; +import type { SandboxRunner, SandboxedPluginInstance, SandboxOptions } from "./types.js"; /** * Error thrown when attempting to use sandboxing on an unsupported platform. @@ -48,7 +48,7 @@ export class NoopSandboxRunner implements SandboxRunner { _manifest: PluginManifest, // eslint-disable-next-line @typescript-eslint/no-unused-vars _code: string, - ): Promise { + ): Promise { throw new SandboxNotAvailableError(); } diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index 716594ec0..22e50296d 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -78,10 +78,13 @@ export interface SandboxOptions { } /** - * A sandboxed plugin instance. - * Provides methods to invoke hooks and routes in the isolated environment. + * Handle to a sandboxed plugin running inside an isolate. Returned + * by `SandboxRunner.load` and held by the runtime's cache so hooks / + * routes can be invoked across the isolate boundary. Distinct from + * the author-facing `SandboxedPlugin` type in `emdash/plugin`, which + * describes the source-level shape of a plugin's default export. */ -export interface SandboxedPlugin { +export interface SandboxedPluginInstance { /** Unique identifier: `${manifest.id}:${manifest.version}` */ readonly id: string; @@ -142,7 +145,7 @@ export interface SandboxRunner { * @returns A sandboxed plugin instance * @throws If sandboxing is not available or plugin can't be loaded */ - load(manifest: PluginManifest, code: string): Promise; + load(manifest: PluginManifest, code: string): Promise; /** * Set the email send callback for sandboxed plugins. diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 359b7b531..b743ed1b8 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -12,7 +12,7 @@ import type { Element } from "@emdash-cms/blocks"; // The plugin capability vocabulary, the legacy-rename map, and the manifest // shape are authored once in @emdash-cms/plugin-types and shared between core -// (the manifest reader at install/runtime) and @emdash-cms/registry-cli (the +// (the manifest reader at install/runtime) and @emdash-cms/plugin-cli (the // manifest writer at bundle/publish time). // // We import-and-re-export here so existing internal callers keep working @@ -58,7 +58,7 @@ export { // // `StorageCollectionConfig` and `PluginStorageConfig` are re-exported above // from `@emdash-cms/plugin-types`. The manifest carries these shapes -// verbatim; both this package (reader) and registry-cli (writer) agree on +// verbatim; both this package (reader) and plugin-cli (writer) agree on // the same types via the shared package. /** @@ -1303,83 +1303,6 @@ export interface ResolvedPluginHooks { "page:fragments"?: ResolvedHook; } -// ============================================================================= -// Standard Plugin Format (Unified Plugin Format) -// ============================================================================= - -/** - * Standard plugin hook handler -- same as sandbox entry format. - * Receives the event as the first argument and a PluginContext as the second. - * - * Plugin authors annotate their event parameters with specific types for IDE - * support. At the type level, we accept any function with compatible arity. - */ -// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event types -export type StandardHookHandler = (...args: any[]) => Promise; - -/** - * Standard plugin hook entry -- either a bare handler or a config object. - */ -export type StandardHookEntry = - | StandardHookHandler - | { - handler: StandardHookHandler; - priority?: number; - timeout?: number; - dependencies?: string[]; - errorPolicy?: "continue" | "abort"; - exclusive?: boolean; - }; - -/** - * Standard plugin route handler -- takes (routeCtx, pluginCtx) like sandbox entries. - * The routeCtx contains input and request info; pluginCtx is the full plugin context. - * - * Uses `any` for routeCtx to allow plugins to access properties like - * `routeCtx.request.url` without needing exact type matches across - * trusted (Request object) and sandboxed (plain object) modes. - */ -// eslint-disable-next-line typescript-eslint/no-explicit-any -- see above -export type StandardRouteHandler = (routeCtx: any, ctx: PluginContext) => Promise; - -/** - * Standard plugin route entry -- either a config object with handler, or just a handler. - */ -export interface StandardRouteEntry { - handler: StandardRouteHandler; - input?: unknown; - public?: boolean; -} - -/** - * Standard plugin definition -- the sandbox entry format. - * Used by standard plugins that work in both trusted and sandboxed modes. - * No id/version/capabilities -- those come from the descriptor. - * - * This is the input to definePlugin() for standard-format plugins. - * - * The hooks and routes use permissive types (Record) so that - * plugin authors can annotate their handlers with specific event types - * without type errors from strictFunctionTypes contravariance. - */ -export interface StandardPluginDefinition { - // eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types - hooks?: Record; - // eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types - routes?: Record; -} - -/** - * Check if a value is a StandardPluginDefinition (has hooks/routes but no id/version). - */ -export function isStandardPluginDefinition(value: unknown): value is StandardPluginDefinition { - if (typeof value !== "object" || value === null) return false; - // Standard format: has hooks or routes, but NOT id+version (which are on PluginDefinition) - const hasPluginShape = "hooks" in value || "routes" in value; - const hasNativeShape = "id" in value && "version" in value; - return hasPluginShape && !hasNativeShape; -} - // ============================================================================= // Plugin Admin Exports // ============================================================================= diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index deea7a995..4e393ea2b 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -9,12 +9,18 @@ import { describe, it, expect, vi } from "vitest"; import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js"; +import type { SandboxedPlugin } from "../../../src/plugin-types.js"; import { adaptSandboxEntry } from "../../../src/plugins/adapt-sandbox-entry.js"; -import type { StandardPluginDefinition, StandardHookHandler } from "../../../src/plugins/types.js"; -/** Create a properly typed mock hook handler */ -function mockHandler(): StandardHookHandler { - return vi.fn(async () => {}) as unknown as StandardHookHandler; +/** + * Create a mock hook handler with a loose signature. The strict + * mapped type on `SandboxedPlugin` ties handler shape to hook name; + * tests building fixtures across many hooks construct each entry as + * the union, so a single mock factory returns a handler typed as + * `() => Promise` and TypeScript widens when assigned. + */ +function mockHandler(): () => Promise { + return vi.fn(async () => {}); } function createDescriptor(overrides?: Partial): PluginDescriptor { @@ -30,7 +36,7 @@ function createDescriptor(overrides?: Partial): PluginDescript describe("adaptSandboxEntry", () => { describe("basic adaptation", () => { it("produces a ResolvedPlugin with correct id and version", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: {}, routes: {}, }; @@ -43,7 +49,7 @@ describe("adaptSandboxEntry", () => { }); it("adapts an empty definition", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor(); const result = adaptSandboxEntry(def, descriptor); @@ -56,7 +62,7 @@ describe("adaptSandboxEntry", () => { }); it("carries capabilities from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["content:read", "network:request"], }); @@ -67,7 +73,7 @@ describe("adaptSandboxEntry", () => { }); it("carries allowedHosts from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ allowedHosts: ["api.example.com", "*.cdn.com"], }); @@ -78,7 +84,7 @@ describe("adaptSandboxEntry", () => { }); it("carries storage config from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ storage: { events: { indexes: ["timestamp", "type"] }, @@ -95,7 +101,7 @@ describe("adaptSandboxEntry", () => { }); it("carries admin pages from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ adminPages: [{ path: "/settings", label: "Settings", icon: "gear" }], }); @@ -106,7 +112,7 @@ describe("adaptSandboxEntry", () => { }); it("carries admin widgets from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ adminWidgets: [{ id: "status", title: "Status", size: "half" }], }); @@ -120,7 +126,7 @@ describe("adaptSandboxEntry", () => { describe("hook adaptation", () => { it("resolves a bare function hook with defaults", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:afterSave": handler, }, @@ -142,7 +148,7 @@ describe("adaptSandboxEntry", () => { it("resolves a config object hook with custom settings", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:beforeSave": { handler, @@ -168,7 +174,7 @@ describe("adaptSandboxEntry", () => { }); it("resolves multiple hooks", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:beforeSave": mockHandler(), "content:afterSave": { handler: mockHandler(), priority: 200 }, @@ -189,7 +195,7 @@ describe("adaptSandboxEntry", () => { }); it("sets pluginId on all hooks from descriptor", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:beforeSave": mockHandler(), "content:afterSave": { handler: mockHandler() }, @@ -205,7 +211,7 @@ describe("adaptSandboxEntry", () => { it("resolves exclusive hooks", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "email:deliver": { handler, @@ -221,7 +227,7 @@ describe("adaptSandboxEntry", () => { }); it("throws on unknown hook names", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "unknown:hook": mockHandler(), }, @@ -233,7 +239,7 @@ describe("adaptSandboxEntry", () => { it("applies default config for partial config objects", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:afterSave": { handler, @@ -259,7 +265,7 @@ describe("adaptSandboxEntry", () => { it("wraps standard two-arg route handler into single-arg RouteContext handler", async () => { const standardHandler = vi.fn().mockResolvedValue({ ok: true }); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { routes: { status: { handler: standardHandler, @@ -304,7 +310,7 @@ describe("adaptSandboxEntry", () => { }); it("preserves public flag on routes", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { routes: { webhook: { handler: vi.fn(), @@ -320,7 +326,7 @@ describe("adaptSandboxEntry", () => { }); it("adapts multiple routes", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { routes: { status: { handler: vi.fn() }, sync: { handler: vi.fn() }, @@ -337,7 +343,7 @@ describe("adaptSandboxEntry", () => { describe("capability normalization", () => { it("normalizes content:write to include content:read", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["content:write"] }); const result = adaptSandboxEntry(def, descriptor); @@ -347,7 +353,7 @@ describe("adaptSandboxEntry", () => { }); it("normalizes media:write to include media:read", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["media:write"] }); const result = adaptSandboxEntry(def, descriptor); @@ -357,7 +363,7 @@ describe("adaptSandboxEntry", () => { }); it("normalizes network:request:unrestricted to include network:request", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["network:request:unrestricted"] }); const result = adaptSandboxEntry(def, descriptor); @@ -367,7 +373,7 @@ describe("adaptSandboxEntry", () => { }); it("does not duplicate implied capabilities", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["content:read", "content:write"], }); @@ -379,7 +385,7 @@ describe("adaptSandboxEntry", () => { }); it("throws on invalid capability", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["invalid:capability"], }); @@ -394,7 +400,7 @@ describe("adaptSandboxEntry", () => { // the runtime only sees the new shape. it("rewrites all deprecated capability names to current names", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: [ "read:content", @@ -442,7 +448,7 @@ describe("adaptSandboxEntry", () => { }); it("deduplicates when both deprecated and current names are present", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["read:content", "content:read"], }); @@ -459,7 +465,7 @@ describe("adaptSandboxEntry", () => { // HookPipeline stores hooks as ResolvedHook internally. // The adapted hooks must have the expected shape. const handler = vi.fn().mockResolvedValue(undefined); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:afterSave": { handler, diff --git a/packages/core/tests/unit/plugins/define-plugin.test.ts b/packages/core/tests/unit/plugins/define-plugin.test.ts index d50bbf013..64545b5d4 100644 --- a/packages/core/tests/unit/plugins/define-plugin.test.ts +++ b/packages/core/tests/unit/plugins/define-plugin.test.ts @@ -84,12 +84,15 @@ describe("definePlugin", () => { }); it("rejects empty ID", () => { + // Empty id is treated as "no id" — same code path as the + // sandboxed-shape rejection, with a pointer at the new + // `satisfies SandboxedPlugin` authoring flow. expect(() => definePlugin({ id: "", version: "1.0.0", }), - ).toThrow(INVALID_PLUGIN_ID_PATTERN); + ).toThrow(/requires `id`/); }); it("rejects invalid scoped ID (missing name)", () => { diff --git a/packages/core/tests/unit/plugins/standard-format.test.ts b/packages/core/tests/unit/plugins/standard-format.test.ts index 3ee95d36d..13980b31b 100644 --- a/packages/core/tests/unit/plugins/standard-format.test.ts +++ b/packages/core/tests/unit/plugins/standard-format.test.ts @@ -1,9 +1,20 @@ /** - * Standard Plugin Format Tests + * Standard (sandboxed) plugin format tests. * - * Tests the definePlugin() standard format overload, isStandardPluginDefinition(), - * and the generatePluginsModule() standard format handling. + * Covers the runtime + integration side of sandboxed plugins: * + * - `definePlugin` rejects sandboxed-shape input (missing `id`) + * with a helpful message pointing at the new `satisfies + * SandboxedPlugin` pattern. The type system catches this at + * compile time too; this is the bypass-the-type-system runtime + * check. + * - `generatePluginsModule` emits the right import + adapter call + * for sandboxed (`format: "standard"`) plugins vs the native + * `createPlugin` call for native plugins. + * + * Authoring-side tests for `SandboxedPlugin` live next to the + * plugin-types module — strictness of the mapped type is verified + * there. */ import { describe, it, expect, vi } from "vitest"; @@ -11,66 +22,9 @@ import { describe, it, expect, vi } from "vitest"; import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js"; import { generatePluginsModule } from "../../../src/astro/integration/virtual-modules.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; -import { isStandardPluginDefinition } from "../../../src/plugins/types.js"; - -describe("definePlugin() standard format overload", () => { - it("returns the same object (identity function)", () => { - const def = { - hooks: { - "content:afterSave": { - handler: async () => {}, - }, - }, - routes: { - status: { - handler: async () => ({ ok: true }), - }, - }, - }; - - const result = definePlugin(def); - - // Standard format: definePlugin is an identity function - expect(result).toBe(def); - }); - - it("accepts hooks-only definition", () => { - const def = { - hooks: { - "content:beforeSave": async () => {}, - }, - }; - - const result = definePlugin(def); - - expect(result).toBe(def); - expect(result.hooks).toBeDefined(); - }); - - it("accepts routes-only definition", () => { - const def = { - routes: { - ping: { - handler: async () => ({ pong: true }), - }, - }, - }; - - const result = definePlugin(def); - expect(result).toBe(def); - expect(result.routes).toBeDefined(); - }); - - it("throws on empty definition (no hooks or routes)", () => { - // An empty object has no id/version, so it's treated as standard format, - // but standard format requires at least hooks or routes - expect(() => definePlugin({})).toThrow( - "Standard plugin format requires at least `hooks` or `routes`", - ); - }); - - it("still works with native format (id + version)", () => { +describe("definePlugin()", () => { + it("returns a resolved native plugin for input with id + version", () => { const handler = vi.fn(); const result = definePlugin({ id: "native-plugin", @@ -80,53 +34,29 @@ describe("definePlugin() standard format overload", () => { }, }); - // Native format: returns a ResolvedPlugin expect(result.id).toBe("native-plugin"); expect(result.version).toBe("1.0.0"); expect(result.hooks["content:beforeSave"]).toBeDefined(); expect(result.hooks["content:beforeSave"]!.pluginId).toBe("native-plugin"); }); -}); - -describe("isStandardPluginDefinition()", () => { - it("returns true for { hooks: {} }", () => { - expect(isStandardPluginDefinition({ hooks: {} })).toBe(true); - }); - - it("returns true for { routes: {} }", () => { - expect(isStandardPluginDefinition({ routes: {} })).toBe(true); - }); - it("returns true for { hooks: {}, routes: {} }", () => { - expect(isStandardPluginDefinition({ hooks: {}, routes: {} })).toBe(true); + it("throws when called without an id (sandboxed-shape input)", () => { + // The type system rejects this at compile time. At runtime, + // callers who bypass typechecking get a clear pointer at the + // sandboxed authoring flow. + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional type bypass for runtime check coverage + definePlugin({ hooks: {} } as any), + ).toThrow(/SandboxedPlugin/); }); - it("returns false for null", () => { - expect(isStandardPluginDefinition(null)).toBe(false); - }); - - it("returns false for undefined", () => { - expect(isStandardPluginDefinition(undefined)).toBe(false); - }); - - it("returns false for a string", () => { - expect(isStandardPluginDefinition("hello")).toBe(false); - }); - - it("returns false for a native plugin definition (has id + version)", () => { - expect( - isStandardPluginDefinition({ - id: "test", + it("throws when id is the empty string", () => { + expect(() => + definePlugin({ + id: "", version: "1.0.0", - hooks: {}, }), - ).toBe(false); - }); - - it("returns false for an empty object (no hooks or routes)", () => { - // Empty object has neither hooks/routes NOR id/version - // So hasPluginShape is false - expect(isStandardPluginDefinition({})).toBe(false); + ).toThrow(/requires `id`/); }); }); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 4c5a69c0f..09573cae9 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -109,6 +109,8 @@ export default defineConfig({ "src/page/index.ts", // Plugin admin utilities (shared helpers for plugin admin.tsx files) "src/plugin-utils.ts", + // `emdash/plugin` — type-only subpath for sandboxed plugin authors. + "src/plugin-types.ts", // Standard plugin adapter (loaded by virtual:emdash/plugins at runtime) "src/plugins/adapt-sandbox-entry.ts", // Public source-exported subpaths -- compiled so consumers never diff --git a/packages/marketplace/tests/publish-e2e.test.ts b/packages/marketplace/tests/publish-e2e.test.ts deleted file mode 100644 index 3dae032e4..000000000 --- a/packages/marketplace/tests/publish-e2e.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * E2E tests for plugin publishing flow. - * - * Runs the real Hono app with: - * - better-sqlite3 as a D1 mock - * - In-memory Map as R2 mock - * - Seed token auth (skips audit, publishes immediately) - * - * Tests the full path: tarball upload -> manifest validation -> DB write -> R2 store -> public API listing - */ - -import { execSync } from "node:child_process"; -import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve, join } from "node:path"; - -import Database from "better-sqlite3"; -import { describe, it, expect, beforeAll, beforeEach } from "vitest"; - -// Polyfill crypto.subtle.timingSafeEqual (Workers API not in Node) -const subtle = crypto.subtle as unknown as Record; -if (!subtle.timingSafeEqual) { - subtle.timingSafeEqual = (a: ArrayBuffer, b: ArrayBuffer): boolean => { - return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b)); - }; -} - -import app from "../src/app.js"; - -// ── D1 mock using better-sqlite3 ────────────────────────────── - -function createD1Mock() { - const db = new Database(":memory:"); - const schemaPath = resolve(import.meta.dirname, "../src/db/schema.sql"); - const schema = readFileSync(schemaPath, "utf-8"); - db.exec(schema); - - return { - _db: db, - prepare(query: string) { - return { - _query: query, - _bindings: [] as unknown[], - bind(...args: unknown[]) { - this._bindings = args; - return this; - }, - async first(column?: string): Promise { - const stmt = db.prepare(this._query); - const row = stmt.get(...this._bindings) as Record | undefined; - if (!row) return null; - if (column) return (row[column] ?? null) as T; - return row as T; - }, - async all(): Promise<{ results: T[] }> { - const stmt = db.prepare(this._query); - const rows = stmt.all(...this._bindings) as T[]; - return { results: rows }; - }, - async run() { - const stmt = db.prepare(this._query); - const result = stmt.run(...this._bindings); - return { - success: true, - meta: { changes: result.changes, last_row_id: result.lastInsertRowid }, - }; - }, - }; - }, - async batch(statements: { _query: string; _bindings: unknown[] }[]) { - const results = []; - for (const stmt of statements) { - const s = db.prepare(stmt._query); - results.push(s.run(...stmt._bindings)); - } - return results; - }, - }; -} - -// ── R2 mock ──────────────────────────────────────────────────── - -function createR2Mock() { - const store = new Map }>(); - return { - async put( - key: string, - data: ArrayBuffer | Uint8Array | ReadableStream, - opts?: { httpMetadata?: { contentType?: string } }, - ) { - let buf: ArrayBuffer; - if (data instanceof ArrayBuffer) { - buf = data; - } else if (ArrayBuffer.isView(data)) { - buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; - } else { - const reader = (data as ReadableStream).getReader(); - const chunks: Uint8Array[] = []; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); - } - const total = chunks.reduce((acc, c) => acc + c.length, 0); - const merged = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; - } - buf = merged.buffer as ArrayBuffer; - } - store.set(key, { data: buf, metadata: opts?.httpMetadata }); - }, - async get(key: string) { - const entry = store.get(key); - if (!entry) return null; - return { - arrayBuffer: async () => entry.data, - body: new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(entry.data)); - controller.close(); - }, - }), - }; - }, - async head(key: string) { - return store.has(key) ? { size: store.get(key)!.data.byteLength } : null; - }, - _store: store, - }; -} - -// ── Test fixtures ────────────────────────────────────────────── - -const RE_EXTRACT_OR_TARBALL = /extract|tarball/i; -const SEED_TOKEN = "test-seed-token-for-e2e"; -const REPO_ROOT = resolve(import.meta.dirname, "../../.."); - -let auditLogTarball: Buffer; -let auditLogVersion: string; - -beforeAll(async () => { - // Build the audit-log plugin tarball - execSync("node packages/core/dist/cli/index.mjs plugin bundle --dir packages/plugins/audit-log", { - cwd: REPO_ROOT, - stdio: "pipe", - }); - - const auditLogPkg = JSON.parse( - readFileSync(join(REPO_ROOT, "packages/plugins/audit-log/package.json"), "utf-8"), - ) as { version: string }; - auditLogVersion = auditLogPkg.version; - - const distDir = join(REPO_ROOT, "packages/plugins/audit-log/dist"); - const tarballName = `audit-log-${auditLogVersion}.tar.gz`; - auditLogTarball = await readFile(join(distDir, tarballName)); -}, 30000); - -// ── Tests ────────────────────────────────────────────────────── - -describe("marketplace publish e2e", () => { - let env: Record; - - beforeEach(() => { - env = { - DB: createD1Mock(), - R2: createR2Mock(), - SEED_TOKEN, - GITHUB_CLIENT_ID: "test", - GITHUB_CLIENT_SECRET: "test-secret", - AUDIT_ENFORCEMENT: "none", - }; - }); - - it("publishes a plugin tarball via seed auth and lists it", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - - const publishRes = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: formData, - }, - env, - ); - - expect(publishRes.status).toBe(201); - const publishBody = (await publishRes.json()) as Record; - expect(publishBody.version).toBe(auditLogVersion); - expect(publishBody.status).toBe("published"); - expect(publishBody.checksum).toBeTruthy(); - - // Verify the plugin is listed - const listRes = await app.request("/api/v1/plugins", {}, env); - expect(listRes.status).toBe(200); - const listBody = (await listRes.json()) as { items: { id: string }[] }; - expect(listBody.items).toHaveLength(1); - expect(listBody.items[0]!.id).toBe("audit-log"); - - // Verify the specific plugin endpoint - const detailRes = await app.request("/api/v1/plugins/audit-log", {}, env); - expect(detailRes.status).toBe(200); - const detailBody = (await detailRes.json()) as { id: string }; - expect(detailBody.id).toBe("audit-log"); - - // Verify the version endpoint - const versionRes = await app.request("/api/v1/plugins/audit-log/versions", {}, env); - expect(versionRes.status).toBe(200); - const versionBody = (await versionRes.json()) as { - items: { version: string; status: string }[]; - }; - expect(versionBody.items).toHaveLength(1); - expect(versionBody.items[0]!.version).toBe(auditLogVersion); - expect(versionBody.items[0]!.status).toBe("published"); - }); - - it("re-publishes same version idempotently via seed auth", async () => { - const makeFormData = () => { - const fd = new FormData(); - fd.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - return fd; - }; - - // First publish - const res1 = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: makeFormData(), - }, - env, - ); - expect(res1.status).toBe(201); - - // Re-publish same version - const res2 = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: makeFormData(), - }, - env, - ); - expect(res2.status).toBe(201); - - // Still only one version - const versionRes = await app.request("/api/v1/plugins/audit-log/versions", {}, env); - const body = (await versionRes.json()) as { items: unknown[] }; - expect(body.items).toHaveLength(1); - }); - - it("rejects publish without auth", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - - const res = await app.request( - "/api/v1/plugins/audit-log/versions", - { method: "POST", body: formData }, - env, - ); - expect(res.status).toBe(401); - }); - - it("rejects invalid tarball", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([new Uint8Array([1, 2, 3])], { type: "application/gzip" }), - "bad.tar.gz", - ); - - const res = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: formData, - }, - env, - ); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(RE_EXTRACT_OR_TARBALL); - }); - - it("rejects wrong seed token", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - - const res = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: "Bearer wrong-token" }, - body: formData, - }, - env, - ); - expect(res.status).toBe(401); - }); -}); diff --git a/packages/registry-cli/.gitignore b/packages/plugin-cli/.gitignore similarity index 100% rename from packages/registry-cli/.gitignore rename to packages/plugin-cli/.gitignore diff --git a/packages/registry-cli/CHANGELOG.md b/packages/plugin-cli/CHANGELOG.md similarity index 100% rename from packages/registry-cli/CHANGELOG.md rename to packages/plugin-cli/CHANGELOG.md diff --git a/packages/plugin-cli/README.md b/packages/plugin-cli/README.md new file mode 100644 index 000000000..a6ce4bf5c --- /dev/null +++ b/packages/plugin-cli/README.md @@ -0,0 +1,141 @@ +# @emdash-cms/plugin-cli + +CLI for authoring, building, and publishing EmDash plugins. + +> EXPERIMENTAL: `init`, `build`, `dev`, `bundle`, `login`, `whoami`, `switch`, and `publish` all work today against any atproto PDS — `publish` writes profile + release records to the publisher's own repo. The discovery commands (`search`, `info`) need an aggregator; the experimental aggregator is at `registry.emdashcms.com`. NSIDs and shapes will change while RFC 0001 is in flight; pin to an exact version. + +## Installation + +```sh +npx @emdash-cms/plugin-cli init my-plugin +``` + +Or install globally: + +```sh +npm install -g @emdash-cms/plugin-cli +emdash-plugin init my-plugin +``` + +## Commands + +```text +emdash-plugin init [name] Scaffold a new sandboxed plugin +emdash-plugin build Build dist/ artifacts (plugin.mjs, manifest.json, index.mjs) +emdash-plugin dev Watch sources and rebuild on change +emdash-plugin bundle Pack dist/ + assets into a registry tarball +emdash-plugin publish --url Publish a release that points at a hosted tarball +emdash-plugin validate [path] Validate emdash-plugin.jsonc against the v1 schema +emdash-plugin login Interactive atproto OAuth login +emdash-plugin logout [--did ] Revoke the active session +emdash-plugin whoami Show stored sessions +emdash-plugin switch Switch the active publisher session +emdash-plugin search Free-text search +emdash-plugin info Show package details +``` + +All commands accept `--json`. Discovery commands accept `--aggregator ` (or `EMDASH_REGISTRY_URL`). + +## Authoring + +A typical plugin's `package.json` scripts: + +```json +{ + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" + } +} +``` + +The plugin author writes two files: + +- `emdash-plugin.jsonc` — identity (slug, publisher) + trust contract (capabilities, allowedHosts, storage) + profile fields. +- `src/plugin.ts` — runtime behaviour (hooks + routes), with `export default { ... } satisfies SandboxedPlugin` from `emdash/plugin`. + +`emdash-plugin build` produces: + +- `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes the integration loads (in-process or in a sandbox isolate). +- `dist/manifest.json` — wire-shape manifest including the hooks + routes harvested from probing `src/plugin.ts`. +- `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module that default-exports a bare `PluginDescriptor`. Consumers import this directly. + +## Publishing + +Three steps. The CLI does not host artifacts — you do, anywhere public. + +```sh +emdash-plugin bundle +# upload dist/-.tar.gz somewhere public +emdash-plugin publish --url https://example.com/foo-1.0.0.tar.gz +``` + +On first publish, pass `--license` and `--security-email` (or `--security-url`) to bootstrap the package profile — or keep them in `emdash-plugin.jsonc` (see below). + +## `emdash-plugin.jsonc` + +Drop an `emdash-plugin.jsonc` file next to your plugin's `package.json`. The CLI reads it automatically from the current directory. Schema-driven IDE completion works via the bundled JSON Schema: + +```jsonc +{ + "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "gallery", + "publisher": "did:plc:abc123def456", + + "license": "MIT", + "author": { "name": "Jane Doe", "url": "https://example.com" }, + "security": { "email": "security@example.com" }, + + // Optional + "name": "Gallery", + "description": "Image gallery block for EmDash.", + "keywords": ["gallery", "images"], + "repo": "https://github.com/example/plugin-gallery", + + // Trust contract + "capabilities": ["content:read"], + "allowedHosts": [], + "storage": {}, +} +``` + +The file is JSONC: comments and trailing commas are allowed. Use `authors: [...]` and `securityContacts: [...]` for multi-author or multi-contact plugins. `version` is optional — when omitted, the CLI reads `version` from the adjacent `package.json`. + +### Publisher pinning + +After your first successful publish, the CLI writes the active session's DID back into the manifest as `publisher`: + +```jsonc +{ + "license": "MIT", + "publisher": "did:plc:abc123def456", + ... +} +``` + +On every subsequent publish, the CLI verifies the active session matches the pinned `publisher`. If they don't match, publish refuses with `MANIFEST_PUBLISHER_MISMATCH` so you can't accidentally publish under the wrong account. To resolve a mismatch, either: + +- switch sessions: `emdash-plugin switch ` +- update the manifest if you're transferring the plugin to a new publisher + +**DIDs are the identity, not handles.** Internally the CLI always compares the active session's DID against the pinned publisher's DID. If you pin a handle (`"publisher": "example.com"`), the CLI resolves it to a DID at publish time and compares against that — so a handle pin is just a friendlier alias for the underlying DID. Handles are mutable: if the publisher's domain changes ownership and the resolver later points at a different DID, the publish will refuse. DIDs are durable and the recommended pin for long-lived plugins. + +Validate without publishing: + +```sh +emdash-plugin validate +``` + +CLI flags (`--license`, `--author-name`, …) still win over manifest values when both are set, which is useful in CI. Pass `--no-manifest` to skip the manifest entirely. + +## Programmatic API + +```ts +import { buildPlugin, bundlePlugin } from "@emdash-cms/plugin-cli"; + +await buildPlugin({ dir: "./my-plugin" }); +const result = await bundlePlugin({ dir: "./my-plugin" }); +``` + +For discovery and credentials, import from `@emdash-cms/registry-client`. diff --git a/packages/registry-cli/package.json b/packages/plugin-cli/package.json similarity index 79% rename from packages/registry-cli/package.json rename to packages/plugin-cli/package.json index dcebb4a16..559043b8e 100644 --- a/packages/registry-cli/package.json +++ b/packages/plugin-cli/package.json @@ -1,7 +1,7 @@ { - "name": "@emdash-cms/registry-cli", + "name": "@emdash-cms/plugin-cli", "version": "0.1.0", - "description": "CLI for publishing plugins to the EmDash plugin registry, and searching it from the terminal. Atproto OAuth, FAIR-shaped records, sandboxed-plugin-only.", + "description": "CLI for authoring, building, and publishing EmDash plugins. Covers init / build / dev / bundle / publish plus registry search and identity. Atproto OAuth, FAIR-shaped records, sandboxed-plugin-only.", "type": "module", "main": "dist/api.mjs", "exports": { @@ -11,7 +11,7 @@ } }, "bin": { - "emdash-registry": "./dist/index.mjs" + "emdash-plugin": "./dist/index.mjs" }, "files": [ "dist", @@ -32,10 +32,12 @@ "@atcute/lexicons": "catalog:", "@atcute/multibase": "catalog:", "@atcute/oauth-node-client": "catalog:", + "@clack/prompts": "^1.4.0", "@emdash-cms/plugin-types": "workspace:*", "@emdash-cms/registry-client": "workspace:*", "@emdash-cms/registry-lexicons": "workspace:*", "@oslojs/crypto": "catalog:", + "chokidar": "catalog:", "citty": "^0.1.6", "consola": "^3.4.2", "image-size": "^2.0.2", @@ -56,6 +58,8 @@ "keywords": [ "emdash", "cms", + "plugin", + "plugin-cli", "plugin-registry", "atproto", "fair", @@ -66,7 +70,7 @@ "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", - "directory": "packages/registry-cli" + "directory": "packages/plugin-cli" }, "homepage": "https://github.com/emdash-cms/emdash" } diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json new file mode 100644 index 000000000..690e5a0d0 --- /dev/null +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -0,0 +1,470 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://emdashcms.com/schemas/emdash-plugin.schema.json", + "title": "EmDash plugin manifest", + "description": "Hand-authored manifest for publishing a plugin to the EmDash plugin registry. Lives next to the plugin's `package.json` as `emdash-plugin.jsonc`.", + "type": "object", + "properties": { + "$schema": { + "$ref": "#/$defs/__schema0" + }, + "slug": { + "$ref": "#/$defs/__schema1" + }, + "version": { + "$ref": "#/$defs/__schema2" + }, + "license": { + "$ref": "#/$defs/__schema3" + }, + "publisher": { + "$ref": "#/$defs/__schema4" + }, + "capabilities": { + "$ref": "#/$defs/__schema5" + }, + "allowedHosts": { + "$ref": "#/$defs/__schema7" + }, + "storage": { + "$ref": "#/$defs/__schema9" + }, + "admin": { + "$ref": "#/$defs/__schema16" + }, + "author": { + "$ref": "#/$defs/__schema27" + }, + "authors": { + "$ref": "#/$defs/__schema32" + }, + "security": { + "$ref": "#/$defs/__schema33" + }, + "securityContacts": { + "$ref": "#/$defs/__schema37" + }, + "name": { + "$ref": "#/$defs/__schema38" + }, + "description": { + "$ref": "#/$defs/__schema39" + }, + "keywords": { + "$ref": "#/$defs/__schema40" + }, + "repo": { + "$ref": "#/$defs/__schema42" + } + }, + "required": [ + "slug", + "license", + "publisher", + "capabilities", + "allowedHosts", + "storage" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "type": "string", + "description": "Path or URL to the JSON Schema describing this file. Editors use this for completion and validation." + }, + "__schema1": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]*$", + "title": "Slug", + "description": "URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.", + "examples": [ + "gallery", + "image-resizer", + "my-plugin" + ] + }, + "__schema2": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", + "title": "Version", + "description": "Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.", + "examples": [ + "0.1.0", + "1.2.3", + "1.0.0-rc.1" + ] + }, + "__schema3": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "title": "License", + "description": "SPDX license expression (e.g. \"MIT\", \"Apache-2.0\", \"MIT OR Apache-2.0\"). Required on first publish; ignored on subsequent publishes (the existing profile wins).", + "examples": [ + "MIT", + "Apache-2.0", + "MIT OR Apache-2.0" + ] + }, + "__schema4": { + "type": "string", + "title": "Publisher", + "description": "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", + "examples": [ + "did:plc:abc123def456", + "example.com" + ] + }, + "__schema5": { + "default": [], + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema6" + }, + "title": "Capabilities", + "description": "Trust contract: what runtime APIs the plugin is allowed to use. Changing this between releases requires a version bump because installed users have consented to the old contract." + }, + "__schema6": { + "type": "string", + "minLength": 1 + }, + "__schema7": { + "default": [], + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/__schema8" + }, + "title": "Allowed hosts", + "description": "Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.", + "examples": [ + [ + "api.example.com", + "*.cdn.example.com" + ] + ] + }, + "__schema8": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "__schema9": { + "default": {}, + "type": "object", + "propertyNames": { + "$ref": "#/$defs/__schema10" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema11" + }, + "title": "Storage", + "description": "Storage collections the plugin uses. Each collection is namespaced to this plugin at runtime." + }, + "__schema10": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "__schema11": { + "type": "object", + "properties": { + "indexes": { + "$ref": "#/$defs/__schema12" + }, + "uniqueIndexes": { + "$ref": "#/$defs/__schema14" + } + }, + "required": [ + "indexes" + ], + "additionalProperties": false, + "title": "Storage collection", + "description": "Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names)." + }, + "__schema12": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema13" + } + } + ] + } + }, + "__schema13": { + "type": "string", + "minLength": 1 + }, + "__schema14": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema15" + } + } + ] + } + }, + "__schema15": { + "type": "string", + "minLength": 1 + }, + "__schema16": { + "type": "object", + "properties": { + "pages": { + "$ref": "#/$defs/__schema17" + }, + "widgets": { + "$ref": "#/$defs/__schema22" + } + }, + "additionalProperties": false, + "title": "Admin surface", + "description": "Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime." + }, + "__schema17": { + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema18" + } + }, + "__schema18": { + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/__schema19" + }, + "label": { + "$ref": "#/$defs/__schema20" + }, + "icon": { + "$ref": "#/$defs/__schema21" + } + }, + "required": [ + "path", + "label" + ], + "additionalProperties": false, + "title": "Admin page", + "description": "A single admin page declaration. The plugin's `admin` route handler is responsible for rendering Block Kit content for this path." + }, + "__schema19": { + "type": "string", + "minLength": 2, + "maxLength": 128, + "pattern": "^\\/[a-z0-9][a-z0-9/_-]*$" + }, + "__schema20": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "__schema21": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "__schema22": { + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema23" + } + }, + "__schema23": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema24" + }, + "title": { + "$ref": "#/$defs/__schema25" + }, + "size": { + "$ref": "#/$defs/__schema26" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "Admin widget", + "description": "A single dashboard widget declaration." + }, + "__schema24": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "__schema25": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "__schema26": { + "type": "string", + "enum": [ + "full", + "half", + "third" + ] + }, + "__schema27": { + "$ref": "#/$defs/__schema28" + }, + "__schema28": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/__schema29" + }, + "url": { + "$ref": "#/$defs/__schema30" + }, + "email": { + "$ref": "#/$defs/__schema31" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "Author", + "description": "A single author entry. Mirrors the lexicon's author shape." + }, + "__schema29": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Display name." + }, + "__schema30": { + "type": "string", + "maxLength": 1024, + "format": "uri", + "description": "Author's homepage or profile URL. Either this or `email` is recommended." + }, + "__schema31": { + "type": "string", + "maxLength": 256, + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Author's contact email. Either this or `url` is recommended." + }, + "__schema32": { + "minItems": 1, + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema28" + }, + "title": "Authors (multiple)", + "description": "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one." + }, + "__schema33": { + "$ref": "#/$defs/__schema34" + }, + "__schema34": { + "type": "object", + "properties": { + "url": { + "$ref": "#/$defs/__schema35" + }, + "email": { + "$ref": "#/$defs/__schema36" + } + }, + "additionalProperties": false, + "title": "Security contact", + "description": "A single security contact. At least one of `url` or `email` must be present." + }, + "__schema35": { + "type": "string", + "maxLength": 1024, + "format": "uri", + "description": "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required." + }, + "__schema36": { + "type": "string", + "maxLength": 256, + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Security contact email. Either this or `url` is required." + }, + "__schema37": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "$ref": "#/$defs/__schema34" + }, + "title": "Security contacts (multiple)", + "description": "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one." + }, + "__schema38": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "title": "Display name", + "description": "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted." + }, + "__schema39": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "title": "Description", + "description": "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists." + }, + "__schema40": { + "maxItems": 5, + "type": "array", + "items": { + "$ref": "#/$defs/__schema41" + }, + "title": "Keywords", + "description": "Search keywords (<= 5 entries, FAIR convention)." + }, + "__schema41": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "__schema42": { + "type": "string", + "maxLength": 1024, + "format": "uri", + "pattern": "^https:\\/\\/", + "title": "Source repository", + "description": "HTTPS URL of the plugin's source repository. Surfaced in registry listings.", + "examples": [ + "https://github.com/emdash-cms/plugin-gallery" + ] + } + } +} diff --git a/packages/registry-cli/scripts/gen-schema.ts b/packages/plugin-cli/scripts/gen-schema.ts similarity index 96% rename from packages/registry-cli/scripts/gen-schema.ts rename to packages/plugin-cli/scripts/gen-schema.ts index 6583b2784..798a10b7f 100644 --- a/packages/registry-cli/scripts/gen-schema.ts +++ b/packages/plugin-cli/scripts/gen-schema.ts @@ -6,7 +6,7 @@ * to `schemas/emdash-plugin.schema.json` and shipped in the package's * `files` array so users can reference it via: * - * "$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json" + * "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json" * * Drift between the Zod schema and the committed JSON Schema is caught * by the snapshot test in `tests/schema.test.ts`. diff --git a/packages/registry-cli/src/api.ts b/packages/plugin-cli/src/api.ts similarity index 87% rename from packages/registry-cli/src/api.ts rename to packages/plugin-cli/src/api.ts index 95d30a785..7b6267e49 100644 --- a/packages/registry-cli/src/api.ts +++ b/packages/plugin-cli/src/api.ts @@ -1,7 +1,7 @@ /** - * Programmatic API for `@emdash-cms/registry-cli`. + * Programmatic API for `@emdash-cms/plugin-cli`. * - * Most users will run the CLI binary `emdash-registry`. This entry exists for + * Most users will run the CLI binary `emdash-plugin`. This entry exists for * tooling -- editors, custom build scripts, or other CLIs -- that want to * invoke the same logic without spawning a subprocess. * @@ -11,6 +11,15 @@ * EXPERIMENTAL: pin to an exact version while RFC 0001 is in flight. */ +export { + type BuildErrorCode, + type BuildLogger, + type BuildOptions, + type BuildResult, + BuildError, + buildPlugin, +} from "./build/api.js"; + export { type BundleErrorCode, type BundleLogger, diff --git a/packages/plugin-cli/src/build/api.ts b/packages/plugin-cli/src/build/api.ts new file mode 100644 index 000000000..480cbce6b --- /dev/null +++ b/packages/plugin-cli/src/build/api.ts @@ -0,0 +1,311 @@ +/** + * Programmatic plugin-build API. + * + * `emdash-plugin build` produces the on-disk distribution artifacts + * for an npm-installed sandboxed plugin: + * + * - `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes (hooks + + * routes), built with `emdash` aliased to a no-op shim. The same + * artifact is consumed two ways at install time: + * 1. In-process (`plugins: [...]`): the integration `import`s the + * package's `./sandbox` export and wraps the default with + * `adaptSandboxEntry`. + * 2. Isolate (`sandboxed: [...]`): the integration resolves the + * same `./sandbox` export, reads the file's bytes, and + * string-embeds them into a generated module the sandbox + * runner loads. + * - `dist/manifest.json` — wire-shape `PluginManifest`. Same shape + * the registry bundle tarball carries; `bundle` packs this file + * verbatim (renaming `plugin.mjs` → `backend.js` inside the + * archive). Includes hooks + routes harvested from probing + * `src/plugin.ts`. + * - `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module, + * default-exporting the bare `PluginDescriptor`. Emitted only + * when a sibling `package.json` exists (registry-only plugins + * skip this because nothing would `import` it). + * + * The plugin author writes only `emdash-plugin.jsonc` + `src/plugin.ts`. + * Identity (slug, publisher) and trust contract (capabilities, + * allowedHosts, storage) come from the manifest; the version is either + * in the manifest or in `package.json#version` (`normaliseManifest` + * reconciles). + * + * Failures throw `BuildError` with a structured `code`. + */ + +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import type { PluginManifest, ResolvedPlugin } from "../bundle/types.js"; +import { extractManifest } from "../bundle/utils.js"; +import type { NormalisedManifest } from "../manifest/translate.js"; +import { + buildRuntime, + probeAndAssemble, + resolveSources, + BuildPipelineError, + type BuildPipelineErrorCode, + type PipelineLogger, + type ResolvedSources, +} from "./pipeline.js"; + +// ────────────────────────────────────────────────────────────────────────── +// Public types +// ────────────────────────────────────────────────────────────────────────── + +export type BuildErrorCode = BuildPipelineErrorCode; + +export class BuildError extends Error { + override readonly name = "BuildError"; + readonly code: BuildErrorCode; + + constructor(code: BuildErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export type BuildLogger = PipelineLogger; + +export interface BuildOptions { + /** Plugin source directory, must contain `emdash-plugin.jsonc` + `src/plugin.ts`. */ + dir: string; + /** + * Output directory for `dist/*`, relative to `dir` if not absolute. + * Defaults to `/dist`. + */ + outDir?: string; + /** Optional progress reporter. */ + logger?: BuildLogger; +} + +export interface BuildResult { + /** The normalised source manifest (post-version-reconciliation). */ + manifest: NormalisedManifest; + /** Package name from `package.json#name`, or `undefined` (registry-only plugin). */ + packageName: string | undefined; + /** + * Wire-shape manifest written to `dist/manifest.json`. Includes + * hooks + routes harvested from probing `src/plugin.ts`. Bundle + * consumes this directly when packing the tarball. + */ + wireManifest: PluginManifest; + /** + * The probed `ResolvedPlugin` — manifest identity + trust contract + * plus harvested hook/route handlers. Bundle uses this for its + * trusted-only / admin-route consistency checks without re-probing. + */ + resolvedPlugin: ResolvedPlugin; + /** Absolute path of the dist directory. */ + outDir: string; + /** Absolute paths of the files produced. */ + files: { + runtime: string; + runtimeTypes: string; + manifestJson: string; + /** Only set when `package.json` exists. */ + descriptor: string | undefined; + /** Only set when `package.json` exists. */ + descriptorTypes: string | undefined; + }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Implementation +// ────────────────────────────────────────────────────────────────────────── + +export async function buildPlugin(options: BuildOptions): Promise { + const log = options.logger ?? {}; + const pluginDir = resolve(options.dir); + const outDir = resolve(pluginDir, options.outDir ?? "dist"); + + log.start?.("Building plugin..."); + + let sources: ResolvedSources; + try { + sources = await resolveSources(pluginDir, log); + } catch (error) { + if (error instanceof BuildPipelineError) { + throw new BuildError(error.code, error.message); + } + throw error; + } + + const tmpDir = await mkdtemp(join(tmpdir(), "emdash-build-")); + + try { + const { build } = await import("tsdown"); + + await mkdir(outDir, { recursive: true }); + + // ── 1. Build src/plugin.ts → dist/plugin.mjs (+ .d.mts) ── + log.start?.("Building runtime entry..."); + const runtimeFiles = await runPipelineStep(() => + buildRuntime({ + entries: sources, + outDir, + tmpDir, + build, + }), + ); + log.success?.("Built plugin.mjs"); + + // ── 2. Probe src/plugin.ts for hooks + routes ── + log.start?.("Probing plugin surface..."); + const resolvedPlugin = await runPipelineStep(() => + probeAndAssemble({ + entries: sources, + tmpDir, + build, + }), + ); + + const wireManifest = extractManifest(resolvedPlugin); + log.info?.( + ` Hooks: ${ + wireManifest.hooks.length > 0 + ? wireManifest.hooks.map((h) => (typeof h === "string" ? h : h.name)).join(", ") + : "(none)" + }`, + ); + log.info?.( + ` Routes: ${ + wireManifest.routes.length > 0 + ? wireManifest.routes.map((r) => (typeof r === "string" ? r : r.name)).join(", ") + : "(none)" + }`, + ); + + // ── 3. Write dist/manifest.json (wire shape) ── + const manifestJson = join(outDir, "manifest.json"); + await writeFile(manifestJson, `${JSON.stringify(wireManifest, null, 2)}\n`, "utf-8"); + log.success?.("Wrote manifest.json"); + + // ── 4. Generate dist/index.mjs (+ .d.mts) — descriptor module ── + // Only emitted when a sibling package.json exists. Registry-only + // plugins (no package.json) can't be `pnpm add`-ed, so nothing + // would `import` the descriptor module. + let descriptor: string | undefined; + let descriptorTypes: string | undefined; + if (sources.hasPackageJson && sources.packageName) { + log.start?.("Generating descriptor module..."); + ({ descriptor, descriptorTypes } = await writeDescriptor({ + outDir, + manifest: sources.manifest, + packageName: sources.packageName, + })); + log.success?.("Wrote index.mjs"); + } else { + log.info?.("No package.json — skipping dist/index.mjs (registry-only plugin)"); + } + + log.success?.(`Plugin built: ${sources.manifest.slug}@${sources.manifest.version}`); + + return { + manifest: sources.manifest, + packageName: sources.packageName, + wireManifest, + resolvedPlugin, + outDir, + files: { + runtime: runtimeFiles.runtime, + runtimeTypes: runtimeFiles.runtimeTypes, + manifestJson, + descriptor, + descriptorTypes, + }, + }; + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +/** + * Translate `BuildPipelineError` to `BuildError`. Other errors pass through. + */ +async function runPipelineStep(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof BuildPipelineError) { + throw new BuildError(error.code, error.message); + } + throw error; + } +} + +interface WriteDescriptorContext { + outDir: string; + manifest: NormalisedManifest; + packageName: string; +} + +interface DescriptorFiles { + descriptor: string; + descriptorTypes: string; +} + +/** + * Emit `dist/index.mjs` + `dist/index.d.mts`. + * + * The descriptor is a frozen plain object — no factory call, no named + * exports. Consumers write `import auditLog from "@.../plugin-audit-log"` + * and pass `auditLog` into the integration's `plugins:` or `sandboxed:` + * array directly. Per-install configuration moves to the admin UI's + * settings (KV-backed) so the import shape has no need for a factory. + * + * The descriptor's `entrypoint` is `/sandbox`. Plugins + * MUST expose a `./sandbox` export in their `package.json` pointing at + * `./dist/plugin.mjs` — the runtime bytes the integration loads. + */ +async function writeDescriptor(ctx: WriteDescriptorContext): Promise { + const { outDir, manifest, packageName } = ctx; + + const descriptorObject = { + id: manifest.slug, + version: manifest.version, + format: "standard" as const, + entrypoint: `${packageName}/sandbox`, + capabilities: manifest.capabilities, + allowedHosts: manifest.allowedHosts, + storage: manifest.storage, + ...(manifest.admin.pages.length > 0 ? { adminPages: manifest.admin.pages } : {}), + ...(manifest.admin.widgets.length > 0 ? { adminWidgets: manifest.admin.widgets } : {}), + }; + + // Pretty-print so the generated file is human-readable when debugging. + // Tab-indent for consistency with the surrounding generated file's + // surrounding tab-based formatting; matches the project's oxfmt config. + const descriptorLiteral = JSON.stringify(descriptorObject, null, "\t"); + + const descriptorSource = `// Auto-generated by emdash-plugin build. Do not edit. +// Source: emdash-plugin.jsonc + package.json +// +// Default-exports a sandboxed plugin descriptor. Pass it directly into +// emdash's \`plugins:\` or \`sandboxed:\` array — no factory call needed. + +/** @type {import("emdash").PluginDescriptor} */ +const descriptor = Object.freeze(${descriptorLiteral}); + +export default descriptor; +`; + + const descriptorPath = join(outDir, "index.mjs"); + await writeFile(descriptorPath, descriptorSource, "utf-8"); + + const descriptorTypesSource = `// Auto-generated by emdash-plugin build. Do not edit. +import type { PluginDescriptor } from "emdash"; + +declare const descriptor: PluginDescriptor; +export default descriptor; +`; + const descriptorTypesPath = join(outDir, "index.d.mts"); + await writeFile(descriptorTypesPath, descriptorTypesSource, "utf-8"); + + return { descriptor: descriptorPath, descriptorTypes: descriptorTypesPath }; +} diff --git a/packages/plugin-cli/src/build/command.ts b/packages/plugin-cli/src/build/command.ts new file mode 100644 index 000000000..342f64677 --- /dev/null +++ b/packages/plugin-cli/src/build/command.ts @@ -0,0 +1,69 @@ +/** + * `emdash-plugin build` + * + * Thin citty wrapper around `buildPlugin` from `./api.js`. Produces the + * on-disk dist artifacts for an npm-installed sandboxed plugin: + * + * - `dist/plugin.mjs` (+ `.d.mts`) — runtime bytes (hooks + routes). + * - `dist/index.mjs` (+ `.d.mts`) — descriptor module, bare default export. + * - `dist/manifest.json` — normalised manifest, kept in sync. + * + * Plugin authors run this from their package's `prepublishOnly` (or a + * `pnpm build` script that delegates). Bundling for the registry (the + * tarball form) is a separate command — see `emdash-plugin bundle`. + */ + +import { defineCommand } from "citty"; +import consola from "consola"; +import pc from "picocolors"; + +import { BuildError, buildPlugin, type BuildLogger } from "./api.js"; + +export const buildCommand = defineCommand({ + meta: { + name: "build", + description: "Build a sandboxed plugin's npm distribution artifacts", + }, + args: { + dir: { + type: "string", + description: "Plugin directory (default: current directory)", + default: process.cwd(), + }, + outDir: { + type: "string", + alias: "o", + description: "Output directory (default: ./dist)", + default: "dist", + }, + }, + async run({ args }) { + const logger: BuildLogger = { + start: (m) => consola.start(m), + info: (m) => consola.info(m), + success: (m) => consola.success(m), + warn: (m) => consola.warn(m), + }; + + let result; + try { + result = await buildPlugin({ + dir: args.dir, + outDir: args.outDir, + logger, + }); + } catch (error) { + if (error instanceof BuildError) { + consola.error(error.message); + process.exit(1); + } + throw error; + } + + console.log(); + consola.info("Output:"); + console.log(` ${pc.cyan(result.files.descriptor)}`); + console.log(` ${pc.cyan(result.files.runtime)}`); + console.log(` ${pc.cyan(result.files.manifestJson)}`); + }, +}); diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts new file mode 100644 index 000000000..f94bd3588 --- /dev/null +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -0,0 +1,496 @@ +/** + * Shared build pipeline used by `build` and `bundle`. + * + * One canonical source-to-artifact pipeline so neither `build` nor `bundle` + * has to maintain its own copy of the probe + transpile + extract logic. + * `build` writes the dist artifacts to disk; `bundle` calls into the same + * machinery to produce a `ResolvedPlugin` it then validates and tarballs. + * + * The phases: + * + * 1. `resolveSources(pluginDir)` — read + normalise `emdash-plugin.jsonc`, + * optionally read `package.json` for name/version, locate `src/plugin.ts`. + * Reconciles the manifest's optional `version` with `package.json#version` + * via `normaliseManifest` (mismatch / missing → error). + * + * 2. `probeAndAssemble({ entries, tmpDir })` — build `src/plugin.ts` + * unminified to a temp file, dynamically `import()` it, and harvest + * the hook/route surface into a `ResolvedPlugin`. Identity + trust + * contract come from the manifest, not the code. + * + * 3. `buildRuntime({ entries, outDir, tmpDir })` — build `src/plugin.ts` + * again, this time minified + tree-shaken + with `.d.mts` types, to + * produce `/plugin.mjs` and `/plugin.d.mts`. Probe + * and runtime builds differ deliberately in minification and dts + * output; the probe only reads `default.hooks` / `default.routes` + * *keys*, which minification doesn't rename (object literal keys + * stay stable). Both pass the same source through tsdown with no + * `external` and no `alias` — sandboxed plugins must not import + * from `emdash` at runtime (types come from `emdash/plugin` and + * are erased before bundling). + * + * Errors throw `BuildPipelineError` with a structured code. Wrappers translate + * to their own error classes so the CLI's `BuildError` / `BundleError` + * surfaces don't change. + */ + +import { copyFile, mkdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import type { ResolvedPlugin } from "../bundle/types.js"; +import { fileExists } from "../bundle/utils.js"; +import { + ManifestError, + MANIFEST_FILENAME, + loadManifest, + type LoadManifestResult, +} from "../manifest/load.js"; +import { + normaliseManifest, + VersionMismatchError, + type NormalisedManifest, +} from "../manifest/translate.js"; + +const PLUGIN_ENTRY_PATH = "src/plugin.ts"; +const PACKAGE_JSON_PATH = "package.json"; + +// ────────────────────────────────────────────────────────────────────────── +// Errors +// ────────────────────────────────────────────────────────────────────────── + +export type BuildPipelineErrorCode = + | "MISSING_MANIFEST" + | "MISSING_PLUGIN_ENTRY" + | "MANIFEST_INVALID" + | "PACKAGE_JSON_INVALID" + | "VERSION_MISMATCH" + | "VERSION_MISSING" + | "RUNTIME_BUILD_FAILED" + | "PROBE_BUILD_FAILED" + | "INVALID_PLUGIN_FORMAT"; + +export class BuildPipelineError extends Error { + override readonly name = "BuildPipelineError"; + readonly code: BuildPipelineErrorCode; + + constructor(code: BuildPipelineErrorCode, message: string) { + super(message); + this.code = code; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Logger surface (shared by build + bundle wrappers) +// ────────────────────────────────────────────────────────────────────────── + +export interface PipelineLogger { + start?(message: string): void; + info?(message: string): void; + success?(message: string): void; + warn?(message: string): void; +} + +// ────────────────────────────────────────────────────────────────────────── +// Phase 1: source resolution +// ────────────────────────────────────────────────────────────────────────── + +export interface ResolvedSources { + pluginDir: string; + pluginEntry: string; + manifest: NormalisedManifest; + manifestPath: string; + /** + * Package name from `package.json#name`, or `undefined` if no + * `package.json` exists (registry-only plugin). + */ + packageName: string | undefined; + /** + * Whether a sibling `package.json` was found. Determines whether + * the descriptor module (`dist/index.mjs`) is emitted — a plugin + * without `package.json` can't be `pnpm add`-ed, so the descriptor + * has no consumer. + */ + hasPackageJson: boolean; +} + +export async function resolveSources( + pluginDir: string, + log: PipelineLogger = {}, +): Promise { + const resolvedDir = resolve(pluginDir); + const manifestPath = join(resolvedDir, MANIFEST_FILENAME); + + if (!(await fileExists(manifestPath))) { + throw new BuildPipelineError( + "MISSING_MANIFEST", + `No ${MANIFEST_FILENAME} found in ${resolvedDir}. Scaffold one with: emdash-plugin init`, + ); + } + + let loaded: LoadManifestResult; + try { + loaded = await loadManifest(manifestPath); + } catch (error) { + if (error instanceof ManifestError) { + throw new BuildPipelineError("MANIFEST_INVALID", error.message); + } + throw error; + } + + const pluginEntry = join(resolvedDir, PLUGIN_ENTRY_PATH); + if (!(await fileExists(pluginEntry))) { + throw new BuildPipelineError( + "MISSING_PLUGIN_ENTRY", + `No ${PLUGIN_ENTRY_PATH} found in ${resolvedDir}. Sandboxed plugins place their routes and hooks in this single file.`, + ); + } + + // `package.json` is optional. Common case (npm-distributed plugin): + // present, drives the version and the descriptor's entrypoint + // specifier. Edge case (registry-only plugin): absent, version + // lives in the manifest, no descriptor module is emitted. + const packageJsonPath = join(resolvedDir, PACKAGE_JSON_PATH); + const hasPackageJson = await fileExists(packageJsonPath); + let packageName: string | undefined; + let packageVersion: string | undefined; + if (hasPackageJson) { + ({ packageName, packageVersion } = await readPackageMeta(packageJsonPath)); + } + + let manifest: NormalisedManifest; + try { + manifest = normaliseManifest(loaded.manifest, packageVersion); + } catch (error) { + if (error instanceof VersionMismatchError) { + throw new BuildPipelineError(error.code, error.message); + } + throw error; + } + + log.info?.(`Manifest: ${loaded.path}`); + log.info?.(`Plugin entry: ${pluginEntry}`); + if (packageName) log.info?.(`Package: ${packageName}`); + + return { + pluginDir: resolvedDir, + pluginEntry, + manifest, + manifestPath: loaded.path, + packageName, + hasPackageJson, + }; +} + +interface PackageMeta { + packageName: string; + packageVersion: string | undefined; +} + +async function readPackageMeta(packageJsonPath: string): Promise { + const source = await readFile(packageJsonPath, "utf-8"); + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new BuildPipelineError("PACKAGE_JSON_INVALID", `${packageJsonPath} is not valid JSON.`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} must be a JSON object.`, + ); + } + const name = (parsed as { name?: unknown }).name; + if (typeof name !== "string" || name.length === 0) { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} has no "name" field. The build derives the runtime entrypoint specifier from package.json#name.`, + ); + } + // `version` is optional (registry-only plugins may rely on the + // manifest's version); when present, it must be a non-empty + // string. + const versionRaw = (parsed as { version?: unknown }).version; + let packageVersion: string | undefined; + if (versionRaw === undefined) { + packageVersion = undefined; + } else if (typeof versionRaw === "string" && versionRaw.length > 0) { + packageVersion = versionRaw; + } else { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} has a non-string or empty \`version\` (${JSON.stringify(versionRaw)}). Either remove the field (registry-only plugins) or set it to a non-empty string.`, + ); + } + return { packageName: name, packageVersion }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Phase 2: probe + assemble +// ────────────────────────────────────────────────────────────────────────── + +export interface ProbeAndAssembleContext { + entries: ResolvedSources; + tmpDir: string; + build: typeof import("tsdown").build; +} + +/** + * Build `src/plugin.ts` once for hook/route probing, import it, and + * assemble a `ResolvedPlugin` from the manifest's identity / trust + * contract plus the probed surface. + * + * The probe build is *not* minified — keeping function bodies intact + * makes the `pluginModule.default.hooks[x]` handler reads stable. The + * later runtime build is minified separately by `buildRuntime`. + */ +export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise { + const { entries, tmpDir, build } = ctx; + + const resolvedPlugin: ResolvedPlugin = { + // `id` on the bundled manifest is the publisher's natural slug. + // The runtime rewrites it to the opaque `r_` at install + // time (see makeRegistryPluginId), but on-wire the slug is what + // the install handler matches against the registry's record key. + id: entries.manifest.slug, + version: entries.manifest.version, + capabilities: entries.manifest.capabilities, + allowedHosts: entries.manifest.allowedHosts, + storage: entries.manifest.storage, + hooks: {}, + routes: {}, + admin: { + pages: entries.manifest.admin.pages, + widgets: entries.manifest.admin.widgets, + }, + }; + + const probeOutDir = join(tmpDir, "plugin-probe"); + + try { + await build({ + config: false, + entry: { plugin: entries.pluginEntry }, + format: "esm", + outExtensions: () => ({ js: ".mjs" }), + outDir: probeOutDir, + dts: false, + platform: "neutral", + external: [], + treeshake: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new BuildPipelineError( + "PROBE_BUILD_FAILED", + `Failed to probe ${entries.pluginEntry}: ${message}`, + ); + } + + const probeOutputPath = join(probeOutDir, "plugin.mjs"); + if (!(await fileExists(probeOutputPath))) { + throw new BuildPipelineError( + "PROBE_BUILD_FAILED", + `Probe of ${entries.pluginEntry} produced no output at ${probeOutputPath}.`, + ); + } + + const pluginModule = (await import(probeOutputPath)) as Record; + if (pluginModule.default === undefined) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry} has no \`default\` export. Sandboxed plugins must \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin". A named-only export (e.g. \`export const plugin = ...\`) produces an empty bundle.`, + ); + } + const definition = pluginModule.default as Record; + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry} must default-export an object with \`hooks\` and/or \`routes\` (sandboxed plugin shape: \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin"). Got ${describeShape(definition)}.`, + ); + } + + const hooks = definition.hooks as Record | undefined; + const routes = definition.routes as Record | undefined; + + if (hooks) { + for (const hookName of Object.keys(hooks)) { + const hookEntry = hooks[hookName]; + const handler = extractHookHandler(hookEntry); + if (!handler) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, + ); + } + const config: Record = + typeof hookEntry === "object" && hookEntry !== null + ? (hookEntry as Record) + : {}; + // Re-validate hook config values at build time. The strict + // `SandboxedPlugin` type rejects these at compile time; + // this catches authors who bypass typecheck (untyped JS, + // dynamic config). + if ( + config.errorPolicy !== undefined && + config.errorPolicy !== "continue" && + config.errorPolicy !== "abort" + ) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" has invalid errorPolicy ${JSON.stringify(config.errorPolicy)} (must be "continue" or "abort").`, + ); + } + if ( + config.priority !== undefined && + (typeof config.priority !== "number" || !Number.isFinite(config.priority)) + ) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" has invalid priority ${JSON.stringify(config.priority)} (must be a finite number).`, + ); + } + if ( + config.timeout !== undefined && + (typeof config.timeout !== "number" || + !Number.isFinite(config.timeout) || + config.timeout < 0) + ) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" has invalid timeout ${JSON.stringify(config.timeout)} (must be a non-negative finite number).`, + ); + } + resolvedPlugin.hooks[hookName] = { + handler, + priority: (config.priority as number | undefined) ?? 100, + timeout: (config.timeout as number | undefined) ?? 5000, + dependencies: (config.dependencies as string[] | undefined) ?? [], + errorPolicy: (config.errorPolicy as string | undefined) ?? "abort", + exclusive: (config.exclusive as boolean | undefined) ?? false, + pluginId: resolvedPlugin.id, + }; + } + } + if (routes) { + for (const [name, route] of Object.entries(routes)) { + const handler = extractRouteHandler(route); + if (!handler) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, + ); + } + const routeObj: Record = + typeof route === "object" && route !== null ? (route as Record) : {}; + resolvedPlugin.routes[name] = { + handler, + public: routeObj.public as boolean | undefined, + }; + } + } + + return resolvedPlugin; +} + +// ────────────────────────────────────────────────────────────────────────── +// Phase 3: runtime build +// ────────────────────────────────────────────────────────────────────────── + +export interface BuildRuntimeContext { + entries: ResolvedSources; + outDir: string; + tmpDir: string; + build: typeof import("tsdown").build; +} + +export interface RuntimeFiles { + runtime: string; + runtimeTypes: string; +} + +/** + * Build `src/plugin.ts` into `/plugin.mjs` + `/plugin.d.mts`. + * + * Same source as the probe; the configuration differs only in + * `minify: true` and `dts: true`. The probe stays unminified for + * stable property-key reads (`default.hooks`, `default.routes`); the + * runtime build minifies because this output is what runs in the + * isolate (loader string-embeds it) or is `import`-ed in-process. No + * `external`, no `alias` — sandboxed plugins must not import from + * `emdash` at runtime. + */ +export async function buildRuntime(ctx: BuildRuntimeContext): Promise { + const { entries, outDir, tmpDir, build } = ctx; + + const runtimeOutDir = join(tmpDir, "runtime"); + + try { + await build({ + config: false, + entry: { plugin: entries.pluginEntry }, + format: "esm", + outExtensions: () => ({ js: ".mjs", dts: ".d.mts" }), + outDir: runtimeOutDir, + dts: true, + platform: "neutral", + external: [], + minify: true, + treeshake: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new BuildPipelineError( + "RUNTIME_BUILD_FAILED", + `Failed to build ${entries.pluginEntry}: ${message}`, + ); + } + + const builtJs = join(runtimeOutDir, "plugin.mjs"); + if (!(await fileExists(builtJs))) { + throw new BuildPipelineError( + "RUNTIME_BUILD_FAILED", + `Runtime build produced no plugin.mjs output for ${entries.pluginEntry}.`, + ); + } + await mkdir(outDir, { recursive: true }); + const runtime = join(outDir, "plugin.mjs"); + await copyFile(builtJs, runtime); + + const builtDts = join(runtimeOutDir, "plugin.d.mts"); + const runtimeTypes = join(outDir, "plugin.d.mts"); + if (await fileExists(builtDts)) { + await copyFile(builtDts, runtimeTypes); + } + + return { runtime, runtimeTypes }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +function extractHookHandler(entry: unknown): unknown { + if (typeof entry === "function") return entry; + if (entry && typeof entry === "object" && "handler" in entry) { + const handler = (entry as { handler: unknown }).handler; + if (typeof handler === "function") return handler; + } + return undefined; +} + +function extractRouteHandler(entry: unknown): unknown { + if (typeof entry === "function") return entry; + if (entry && typeof entry === "object" && "handler" in entry) { + const handler = (entry as { handler: unknown }).handler; + if (typeof handler === "function") return handler; + } + return undefined; +} + +function describeShape(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (Array.isArray(value)) return `array (length ${value.length})`; + return typeof value; +} diff --git a/packages/plugin-cli/src/bundle/api.ts b/packages/plugin-cli/src/bundle/api.ts new file mode 100644 index 000000000..fbeb675d1 --- /dev/null +++ b/packages/plugin-cli/src/bundle/api.ts @@ -0,0 +1,381 @@ +/** + * Programmatic plugin-bundling API. + * + * Pure-ish core of the bundling pipeline — no `process.exit`, no console + * output. The CLI in `./command.ts` is a thin wrapper that turns these + * calls into pretty terminal output; tests exercise this module directly. + * + * Bundling is "build + validate + tarball". The build phase (probe, + * transpile, manifest extraction) lives in `../build/api.ts`. Bundle + * adds the publish-side concerns on top of build's output: + * + * 1. Run `buildPlugin` to produce `dist/manifest.json` (wire shape) and + * `dist/plugin.mjs` (runtime bytes). + * 2. Validate against publish constraints: no Node-builtin imports in + * the runtime, deprecated capabilities are still flagged, admin + * pages require an admin route, trusted-only features warn, + * bundle-size caps are honoured. + * 3. Stage the tarball contents in a temp dir, renaming `plugin.mjs` + * to `backend.js` (the registry's wire-side name). + * 4. Collect optional assets (README, icon, screenshots). + * 5. Gzip-tar the staging dir into `/-.tar.gz`. + * 6. Compute sha256 and return. + * + * Failures throw `BundleError` with a structured `code` so callers can + * branch (CLI shows a helpful message; tests assert the code). + */ + +import { createHash } from "node:crypto"; +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; + +import { buildPlugin, BuildError, type BuildLogger, type BuildResult } from "../build/api.js"; +import { CAPABILITY_RENAMES, isDeprecatedCapability, type PluginManifest } from "./types.js"; +import { + collectBundleEntries, + createTarball, + fileExists, + findNodeBuiltinImports, + formatBytes, + ICON_SIZE, + MAX_SCREENSHOTS, + MAX_SCREENSHOT_HEIGHT, + MAX_SCREENSHOT_WIDTH, + readImageDimensions, + totalBundleBytes, + validateBundleSize, +} from "./utils.js"; + +const SLASH_RE = /\//g; +const LEADING_AT_RE = /^@/; + +// ────────────────────────────────────────────────────────────────────────── +// Public types +// ────────────────────────────────────────────────────────────────────────── + +export type BundleErrorCode = + // Build-phase failures (passed through from BuildError). + | "MISSING_MANIFEST" + | "MISSING_PLUGIN_ENTRY" + | "MANIFEST_INVALID" + | "PACKAGE_JSON_INVALID" + | "VERSION_MISMATCH" + | "VERSION_MISSING" + | "RUNTIME_BUILD_FAILED" + | "PROBE_BUILD_FAILED" + | "INVALID_PLUGIN_FORMAT" + // Bundle-specific failures. + | "TRUSTED_ONLY_FEATURE" + | "VALIDATION_FAILED"; + +export class BundleError extends Error { + override readonly name = "BundleError"; + readonly code: BundleErrorCode; + + constructor(code: BundleErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export type BundleLogger = BuildLogger; + +export interface BundleOptions { + /** Plugin source directory, must contain `package.json`. */ + dir: string; + /** + * Output directory for the tarball, relative to `dir` if not absolute. + * Defaults to `/dist`. + */ + outDir?: string; + /** + * Skip tarball creation; only run the build + validation. Useful for + * pre-publish checks. Default: `false`. + */ + validateOnly?: boolean; + /** Optional progress reporter. */ + logger?: BundleLogger; +} + +export interface BundleResult { + /** The wire-shape plugin manifest (also written to `dist/manifest.json`). */ + manifest: PluginManifest; + /** Absolute path to the resulting tarball, or `null` when `validateOnly`. */ + tarballPath: string | null; + /** Tarball size in bytes, or `null` when `validateOnly`. */ + tarballBytes: number | null; + /** Hex sha256 of the tarball contents, or `null` when `validateOnly`. */ + sha256: string | null; + /** Non-fatal warnings collected during validation. */ + warnings: string[]; +} + +// ────────────────────────────────────────────────────────────────────────── +// Implementation +// ────────────────────────────────────────────────────────────────────────── + +export async function bundlePlugin(options: BundleOptions): Promise { + const log = options.logger ?? {}; + const pluginDir = resolve(options.dir); + const outDir = resolve(pluginDir, options.outDir ?? "dist"); + const validateOnly = options.validateOnly ?? false; + const warnings: string[] = []; + const warn = (msg: string) => { + warnings.push(msg); + log.warn?.(msg); + }; + + log.start?.(validateOnly ? "Validating plugin..." : "Bundling plugin..."); + + // ── 1. Build dist/ via the shared pipeline ── + let build: BuildResult; + try { + build = await buildPlugin({ dir: pluginDir, outDir, logger: log }); + } catch (error) { + if (error instanceof BuildError) { + throw new BundleError(error.code as BundleErrorCode, error.message); + } + throw error; + } + + const manifest = build.wireManifest; + const resolvedPlugin = build.resolvedPlugin; + + log.success?.(`Plugin: ${manifest.id}@${manifest.version}`); + log.info?.( + ` Capabilities: ${ + manifest.capabilities.length > 0 ? manifest.capabilities.join(", ") : "(none)" + }`, + ); + + // ── 2. Stage tarball contents (rename plugin.mjs -> backend.js) ── + const tmpDir = await mkdtemp(join(tmpdir(), "emdash-bundle-")); + try { + const bundleDir = join(tmpDir, "bundle"); + await mkdir(bundleDir, { recursive: true }); + + // Copy the runtime to `backend.js` (the registry's wire-side + // filename). The marketplace extractor + R2 keys all look for + // `backend.js`; the on-disk `dist/plugin.mjs` keeps a name that + // reads naturally in package.json exports. + await copyFile(build.files.runtime, join(bundleDir, "backend.js")); + + // Copy the wire-shape manifest verbatim. + await copyFile(build.files.manifestJson, join(bundleDir, "manifest.json")); + + // ── 3. Validate bundle contents ── + log.start?.("Validating bundle..."); + const validationErrors: string[] = []; + + // Node builtins in backend.js -> hard fail. + const backendCode = await readFile(join(bundleDir, "backend.js"), "utf-8"); + const builtins = findNodeBuiltinImports(backendCode); + if (builtins.length > 0) { + validationErrors.push( + `backend.js imports Node.js built-in modules: ${builtins.join(", ")}. Sandboxed plugins cannot use Node.js APIs.`, + ); + } + + // Capability sanity warnings. + const declaresUnrestricted = + manifest.capabilities.includes("network:request:unrestricted") || + manifest.capabilities.includes("network:fetch:any"); + const declaresHostRestricted = + manifest.capabilities.includes("network:request") || + manifest.capabilities.includes("network:fetch"); + if (declaresUnrestricted) { + warn( + "Plugin declares unrestricted network access (network:request:unrestricted) — it can make requests to any host.", + ); + } else if (declaresHostRestricted && manifest.allowedHosts.length === 0) { + // `publish` will hard-fail this case (INVALID_MANIFEST) because + // the lexicon says `request: {}` means "unrestricted" -- silently + // publishing that contradicts the apparent intent of declaring + // `network:request` (host-restricted) with empty allowedHosts. + // Surface it loudly at bundle time so the developer fixes it + // before they try to publish. + warn( + "Plugin declares network:request capability but no allowedHosts. The lexicon treats this as `unrestricted` access. Add specific host patterns to allowedHosts, or upgrade the capability to network:request:unrestricted. `publish` will refuse this combination.", + ); + } + + // Deprecated capabilities are warnings here; `publish` hard-fails on them. + const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability); + if (deprecatedCaps.length > 0) { + warn("Plugin uses deprecated capability names. Rename them before publishing:"); + for (const cap of deprecatedCaps) { + warn(` ${cap} -> ${CAPABILITY_RENAMES[cap]}`); + } + } + + // Trusted-only features that won't work in sandboxed mode. + if ( + resolvedPlugin.admin?.portableTextBlocks && + resolvedPlugin.admin.portableTextBlocks.length > 0 + ) { + warn( + "Plugin declares portableTextBlocks — these require trusted mode and will be ignored in sandboxed plugins.", + ); + } + if (resolvedPlugin.admin?.entry) { + warn( + "Plugin declares admin.entry — custom React components require trusted mode. Use Block Kit for sandboxed admin pages.", + ); + } + if (resolvedPlugin.hooks["page:fragments"]) { + warn( + "Plugin declares page:fragments hook — this is trusted-only and will not work in sandboxed mode.", + ); + } + + // Admin pages/widgets require an `admin` route. + const hasAdminPages = (manifest.admin?.pages?.length ?? 0) > 0; + const hasAdminWidgets = (manifest.admin?.widgets?.length ?? 0) > 0; + if (hasAdminPages || hasAdminWidgets) { + const routeNames = manifest.routes.map((r) => (typeof r === "string" ? r : r.name)); + if (!routeNames.includes("admin")) { + const declared = + hasAdminPages && hasAdminWidgets + ? "adminPages and adminWidgets" + : hasAdminPages + ? "adminPages" + : "adminWidgets"; + validationErrors.push( + `Plugin declares ${declared} but the sandbox entry has no "admin" route. Add an admin route handler to serve Block Kit pages.`, + ); + } + } + + // ── 4. Collect optional assets ── + log.start?.("Collecting assets..."); + await collectAssets({ pluginDir, bundleDir, log, warn }); + + // Bundle size caps (RFC 0001 §"Bundle size limits") — measured + // after assets are staged so README/icon/screenshots count. + const bundleEntries = await collectBundleEntries(bundleDir); + const sizeViolations = validateBundleSize(bundleEntries); + if (sizeViolations.length > 0) { + validationErrors.push(...sizeViolations); + } else { + log.info?.( + `Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`, + ); + } + + if (validationErrors.length > 0) { + throw new BundleError( + "VALIDATION_FAILED", + `Bundle validation failed:\n - ${validationErrors.join("\n - ")}`, + ); + } + + log.success?.("Validation passed"); + + // ── 5. Stop here if validateOnly ── + if (validateOnly) { + return { + manifest, + tarballPath: null, + tarballBytes: null, + sha256: null, + warnings, + }; + } + + // ── 6. Create tarball ── + await mkdir(outDir, { recursive: true }); + const tarballName = `${manifest.id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "")}-${manifest.version}.tar.gz`; + const tarballPath = join(outDir, tarballName); + + log.start?.("Creating tarball..."); + await createTarball(bundleDir, tarballPath); + + const tarballStat = await stat(tarballPath); + const tarballBuf = await readFile(tarballPath); + const sha256 = createHash("sha256").update(tarballBuf).digest("hex"); + + log.success?.(`Created ${tarballName} (${(tarballStat.size / 1024).toFixed(1)}KB)`); + log.info?.(` SHA-256: ${sha256}`); + log.info?.(` Path: ${tarballPath}`); + + return { + manifest, + tarballPath, + tarballBytes: tarballStat.size, + sha256, + warnings, + }; + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +interface CollectAssetsContext { + pluginDir: string; + bundleDir: string; + log: BundleLogger; + warn: (msg: string) => void; +} + +async function collectAssets(ctx: CollectAssetsContext): Promise { + const { pluginDir, bundleDir, log, warn } = ctx; + + const readmePath = join(pluginDir, "README.md"); + if (await fileExists(readmePath)) { + await copyFile(readmePath, join(bundleDir, "README.md")); + log.success?.("Included README.md"); + } + + const iconPath = join(pluginDir, "icon.png"); + if (await fileExists(iconPath)) { + const iconBuf = await readFile(iconPath); + const dims = readImageDimensions(iconBuf); + if (!dims) { + warn("icon.png is not a valid PNG — skipping"); + } else { + if (dims[0] !== ICON_SIZE || dims[1] !== ICON_SIZE) { + warn( + `icon.png is ${dims[0]}x${dims[1]}, expected ${ICON_SIZE}x${ICON_SIZE} — including anyway`, + ); + } + await copyFile(iconPath, join(bundleDir, "icon.png")); + log.success?.("Included icon.png"); + } + } + + const screenshotsDir = join(pluginDir, "screenshots"); + if (await fileExists(screenshotsDir)) { + const screenshotFiles = (await readdir(screenshotsDir)) + .filter((f) => { + const ext = extname(f).toLowerCase(); + return ext === ".png" || ext === ".jpg" || ext === ".jpeg"; + }) + .toSorted() + .slice(0, MAX_SCREENSHOTS); + + if (screenshotFiles.length > 0) { + await mkdir(join(bundleDir, "screenshots"), { recursive: true }); + for (const file of screenshotFiles) { + const filePath = join(screenshotsDir, file); + const buf = await readFile(filePath); + const dims = readImageDimensions(buf); + if (!dims) { + warn(`screenshots/${file} — cannot read dimensions, skipping`); + continue; + } + if (dims[0] > MAX_SCREENSHOT_WIDTH || dims[1] > MAX_SCREENSHOT_HEIGHT) { + warn( + `screenshots/${file} is ${dims[0]}x${dims[1]}, max ${MAX_SCREENSHOT_WIDTH}x${MAX_SCREENSHOT_HEIGHT} — including anyway`, + ); + } + await copyFile(filePath, join(bundleDir, "screenshots", file)); + } + log.success?.(`Included ${screenshotFiles.length} screenshot(s)`); + } + } +} diff --git a/packages/registry-cli/src/bundle/command.ts b/packages/plugin-cli/src/bundle/command.ts similarity index 95% rename from packages/registry-cli/src/bundle/command.ts rename to packages/plugin-cli/src/bundle/command.ts index 0e7a9b77f..382e95177 100644 --- a/packages/registry-cli/src/bundle/command.ts +++ b/packages/plugin-cli/src/bundle/command.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry bundle` + * `emdash-plugin bundle` * * Thin citty wrapper around `bundlePlugin` from `./api.js`. The interesting * logic lives there; this file only handles arg parsing, consola formatting, @@ -73,7 +73,7 @@ export const bundleCommand = defineCommand({ console.log(` 1. Upload ${pc.cyan(result.tarballPath)} to a public URL.`); console.log( ` 2. Publish the release record:\n` + - ` ${pc.cyan(`emdash-registry publish --url `)}`, + ` ${pc.cyan(`emdash-plugin publish --url `)}`, ); console.log( ` ${pc.dim(`(or pass --local ${result.tarballPath} to verify the URL serves matching bytes before publishing)`)}`, diff --git a/packages/registry-cli/src/bundle/types.ts b/packages/plugin-cli/src/bundle/types.ts similarity index 100% rename from packages/registry-cli/src/bundle/types.ts rename to packages/plugin-cli/src/bundle/types.ts diff --git a/packages/registry-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts similarity index 100% rename from packages/registry-cli/src/bundle/utils.ts rename to packages/plugin-cli/src/bundle/utils.ts diff --git a/packages/registry-cli/src/commands/info.ts b/packages/plugin-cli/src/commands/info.ts similarity index 98% rename from packages/registry-cli/src/commands/info.ts rename to packages/plugin-cli/src/commands/info.ts index 6d1a24f5b..5a0120bb1 100644 --- a/packages/registry-cli/src/commands/info.ts +++ b/packages/plugin-cli/src/commands/info.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry info ` + * `emdash-plugin info ` * * Show details about a single package. Read-only; no auth required. * diff --git a/packages/plugin-cli/src/commands/init.ts b/packages/plugin-cli/src/commands/init.ts new file mode 100644 index 000000000..5c6d829b5 --- /dev/null +++ b/packages/plugin-cli/src/commands/init.ts @@ -0,0 +1,656 @@ +/** + * `emdash-plugin init [name]` + * + * Scaffold a new sandboxed plugin. Produces the three-file authoring + * contract (manifest + src/plugin.ts + package.json) plus tsconfig, + * README, .gitignore, and a passing test. + * + * Three modes: + * + * 1. Interactive (default on a TTY): clack prompts for each unset + * field with sensible defaults. ESC / Ctrl+C cancels cleanly. + * 2. `--yes` / `-y` (non-interactive): no prompts; unset fields + * become TODO placeholders in the generated manifest. The user + * fixes them before first use. + * 3. Non-TTY (CI, pipes): same as `--yes`. Prompting into a + * non-interactive stdin would hang. + * + * In all modes, explicit flags win — they're treated as final answers + * and skip the prompt for that field. + * + * Exit codes: + * 0 — scaffold written. + * 1 — input validation failed, target conflict (without --force), + * prompt cancelled, or filesystem error. + */ + +import { basename, resolve } from "node:path"; + +import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import * as clack from "@clack/prompts"; +import { isPluginSlug } from "@emdash-cms/plugin-types"; +import { FileCredentialStore } from "@emdash-cms/registry-client"; +import { defineCommand } from "citty"; +import consola from "consola"; +import pc from "picocolors"; + +import { probeEnvironment, type EnvironmentDefaults } from "../init/environment.js"; +import { InitError, scaffold } from "../init/scaffold.js"; +import type { ScaffoldInputs } from "../init/templates.js"; +import { PublisherCheckError, resolveHandleToDid } from "../manifest/publisher.js"; + +export const initCommand = defineCommand({ + meta: { + name: "init", + description: + "Scaffold a new sandboxed plugin: emdash-plugin.jsonc, src/plugin.ts, package.json, tests, and a README.", + }, + args: { + name: { + type: "positional", + required: false, + description: + "Plugin slug. Used as the directory name and the manifest's `slug` field. If omitted, the slug is derived from the current directory name (or prompted in interactive mode).", + }, + dir: { + type: "string", + description: + "Target directory. Defaults to ./ when `name` is given, or the current directory when it isn't.", + }, + publisher: { + type: "string", + description: + "Atproto handle or DID. In interactive mode this is prompted; in --yes mode an unset value becomes a TODO placeholder.", + }, + license: { + type: "string", + description: 'SPDX license expression. Defaults to "MIT".', + }, + "author-name": { + type: "string", + description: "Author name.", + }, + "author-url": { + type: "string", + description: "Author URL.", + }, + "author-email": { + type: "string", + description: "Author email.", + }, + "security-email": { + type: "string", + description: + "Security contact email. Either --security-email or --security-url should be set; in --yes mode an unset value becomes a TODO placeholder.", + }, + "security-url": { + type: "string", + description: "Security contact URL.", + }, + description: { + type: "string", + description: "Short plugin description (omitted from the manifest if not provided).", + }, + repo: { + type: "string", + description: "Source repository URL (omitted from the manifest if not provided).", + }, + yes: { + type: "boolean", + alias: "y", + description: + "Skip interactive prompts. Unset fields become TODO placeholders in the manifest. Automatically enabled when stdin is not a TTY.", + default: false, + }, + force: { + type: "boolean", + description: + "Overwrite existing files in the target directory. Without this flag, init refuses if any target file already exists.", + default: false, + }, + }, + async run({ args }) { + try { + await runInit(args); + } catch (error) { + if (error instanceof InitError || error instanceof InputError) { + consola.error(error.message); + process.exit(1); + } + throw error; + } + }, +}); + +interface InitArgs { + name?: string; + dir?: string; + publisher?: string; + license?: string; + "author-name"?: string; + "author-url"?: string; + "author-email"?: string; + "security-email"?: string; + "security-url"?: string; + description?: string; + repo?: string; + yes?: boolean; + force?: boolean; +} + +async function runInit(args: InitArgs): Promise { + // Non-TTY stdin → can't prompt; behave as if --yes were passed. + // stdout being a pipe is fine (we still write progress); it's the + // input side that has to be a terminal for prompts to work. + const interactive = !(args.yes ?? false) && process.stdin.isTTY === true; + + if (interactive) clack.intro(pc.bold("emdash-plugin init")); + + // Load the active session (if any). Used to pre-fill the publisher + // prompt and to silently fill it in `--yes` mode. We swallow load + // errors entirely — init is reachable from a fresh checkout where + // the credentials store doesn't exist yet, and a corrupt-store + // failure should not block scaffolding. + const session = await loadCurrentSessionSilently(); + + // Resolve slug + target dir. Slug may come from positional, --dir's + // basename, cwd's basename, or (interactive only) a prompt. + let { slug, targetDir } = resolveSlugAndDir(args); + if (nonEmpty(args.name) === undefined && nonEmpty(args.dir) === undefined && interactive) { + const answer = await clack.text({ + message: "Plugin slug", + placeholder: "my-plugin", + defaultValue: slug, + }); + assertNotCancelled(answer); + if (typeof answer === "string" && answer.trim().length > 0) { + slug = answer.trim(); + targetDir = resolve(`./${slug}`); + } + } + + if (!isPluginSlug(slug)) { + throw new InputError( + `Slug "${slug}" is not a valid plugin slug. Expected: lowercase letter, then lowercase letters / digits / "-" / "_" (max 64 chars).`, + ); + } + + // Probe the surrounding environment for pre-fillable defaults + // (git user.name / user.email, git remote URL, package.json fields). + // Probe the target dir if it exists, otherwise cwd — that covers + // both "init into existing repo skeleton" and "init alongside the + // current project" workflows. Failures inside the probe are + // swallowed; missing fields stay undefined. + const env = await probeEnvironment(await pickProbeDir(targetDir)); + + const publisherResult = await resolvePublisher(args, interactive, session); + const license = await resolveLicense(args, interactive, env); + const author = await resolveAuthor(args, interactive, env); + const security = await resolveSecurity(args, interactive); + const description = await resolveDescription(args, interactive, env); + const repo = await resolveRepo(args, interactive, env); + + const inputs: ScaffoldInputs = { + slug, + publisher: publisherResult?.did, + publisherHandle: publisherResult?.handle, + license, + author, + security, + description, + repo, + }; + + const spin = interactive ? clack.spinner() : null; + spin?.start(`Scaffolding ${slug} in ${targetDir}`); + + let result; + try { + result = await scaffold({ + targetDir, + inputs, + force: args.force ?? false, + onFileWritten: interactive + ? undefined + : (relPath) => consola.info(` ${pc.green("+")} ${relPath}`), + }); + } catch (error) { + // `error()` on the spinner reports the failure with the right + // glyph; the outer dispatch handles the actual exit code. + spin?.error("Scaffold failed"); + throw error; + } + + spin?.stop(`Scaffolded ${result.written.length} files`); + if (!interactive) { + consola.success(`Scaffolded ${result.written.length} files in ${targetDir}`); + } + + printNextSteps(targetDir, inputs, interactive); +} + +// ────────────────────────────────────────────────────────────────────────── +// Per-field resolvers. Each consults the flag first; falls through to a +// clack prompt in interactive mode; falls through to `undefined` (→ the +// template emits a TODO) in non-interactive mode. +// ────────────────────────────────────────────────────────────────────────── + +/** + * The publisher resolution result. We always write a DID to the manifest + * (the runtime compares DIDs), but if the user typed a handle (or had + * one from their active session) we carry it through so the rendered + * manifest can emit a `// ` comment next to the pinned DID. + */ +interface PublisherResult { + did: string; + handle: string | undefined; +} + +/** + * Resolve the publisher to write into the manifest. Precedence: + * + * 1. `--publisher` flag (handle or DID; resolved to DID if a handle). + * 2. In `--yes` / non-TTY mode: the active session's handle/DID. + * 3. In interactive mode: a prompt pre-filled with the active session's + * handle (if logged in). + * 4. Otherwise: undefined → manifest gets a TODO placeholder. + * + * For user-typed handles, we eagerly resolve to a DID. The runtime only + * cares about the DID; writing it now means the post-publish write-back + * isn't needed for handle→DID conversion later. + */ +async function resolvePublisher( + args: InitArgs, + interactive: boolean, + session: SessionInfo | undefined, +): Promise { + const flag = nonEmpty(args.publisher); + if (flag !== undefined) { + return await resolvePublisherInput(flag, "--publisher"); + } + + // --yes / non-TTY with an active session: silently fill from session. + // The user can override by passing --publisher; we only reach here + // when they didn't. + if (!interactive) { + if (session) return { did: session.did, handle: session.handle ?? undefined }; + return undefined; + } + + const placeholder = session?.handle ?? "example.com"; + const defaultValue = session?.handle ?? undefined; + + const answer = await clack.text({ + message: session + ? "Atproto publisher (press enter to use your logged-in handle, or type a handle / DID)" + : "Atproto publisher (handle or DID, leave blank to fill in later)", + placeholder, + ...(defaultValue !== undefined && { defaultValue }), + validate: (raw) => { + // clack 1.x types `raw` as `string | undefined` because the + // user can submit without typing anything. Treat that as + // "blank, fine — user wants to fill it in later". + const v = (raw ?? "").trim(); + if (v.length === 0) return undefined; + if (isDid(v) || isHandle(v)) return undefined; + return 'Must be a handle (e.g. "example.com") or DID (e.g. "did:plc:...").'; + }, + }); + assertNotCancelled(answer); + const value = typeof answer === "string" ? answer.trim() : ""; + if (value.length === 0) return undefined; + return await resolvePublisherInput(value, "publisher"); +} + +/** + * Turn a raw publisher input (handle or DID) into a `PublisherResult`. + * DIDs pass through verbatim with no handle. Handles round-trip through + * the atproto resolver to produce a DID; the original handle is carried + * for the manifest comment. + * + * `sourceLabel` is used in error messages to disambiguate "the + * --publisher flag" from "the prompt". + */ +async function resolvePublisherInput(input: string, sourceLabel: string): Promise { + if (isDid(input)) { + return { did: input, handle: undefined }; + } + if (!isHandle(input)) { + throw new InputError( + `${sourceLabel} "${input}" is not a valid atproto handle or DID. Expected a handle (e.g. "example.com") or DID (e.g. "did:plc:abc...").`, + ); + } + try { + const did = await resolveHandleToDid(input); + return { did, handle: input }; + } catch (error) { + if (error instanceof PublisherCheckError) { + throw new InputError(error.message); + } + throw error; + } +} + +async function resolveLicense( + args: InitArgs, + interactive: boolean, + env: EnvironmentDefaults, +): Promise { + const flag = nonEmpty(args.license); + if (flag !== undefined) return flag; + // --yes / non-TTY: take whatever the environment told us, fall + // through to undefined (template defaults to "MIT"). + if (!interactive) return env.license; + const defaultValue = env.license ?? "MIT"; + const answer = await clack.text({ + message: "License (SPDX expression)", + defaultValue, + placeholder: defaultValue, + }); + assertNotCancelled(answer); + const value = typeof answer === "string" ? answer.trim() : ""; + return value.length === 0 ? undefined : value; +} + +async function resolveAuthor(args: InitArgs, interactive: boolean, env: EnvironmentDefaults) { + const flagName = nonEmpty(args["author-name"]); + const flagUrl = nonEmpty(args["author-url"]); + const flagEmail = nonEmpty(args["author-email"]); + + if (flagName !== undefined || flagUrl !== undefined || flagEmail !== undefined) { + // Any author flag set → assemble what we have. Missing sub-fields + // stay undefined; the template only emits the ones that are set. + // Fall back to environment values for the unset sub-fields so + // the user gets a complete author block when their git config + // has the info. + return { + name: flagName ?? env.authorName ?? "TODO: replace with your name", + ...((flagUrl ?? undefined) !== undefined && { url: flagUrl! }), + ...((flagEmail ?? env.authorEmail) !== undefined && { + email: flagEmail ?? env.authorEmail!, + }), + }; + } + + // --yes / non-TTY: use environment defaults only. If git config has + // both name and email, scaffolding picks them up silently. + if (!interactive) { + if (env.authorName === undefined && env.authorEmail === undefined) { + return undefined; + } + return { + name: env.authorName ?? "TODO: replace with your name", + ...(env.authorEmail !== undefined && { email: env.authorEmail }), + }; + } + + const nameAns = await clack.text({ + message: env.authorName + ? "Author name (press enter to use your git config)" + : "Author name (leave blank to fill in later)", + ...(env.authorName !== undefined && { defaultValue: env.authorName }), + placeholder: env.authorName ?? "Jane Doe", + }); + assertNotCancelled(nameAns); + const name = stringOrEmpty(nameAns); + if (name.length === 0) return undefined; + + const urlAns = await clack.text({ + message: "Author URL (optional)", + }); + assertNotCancelled(urlAns); + const url = stringOrEmpty(urlAns); + + const emailAns = await clack.text({ + message: env.authorEmail + ? "Author email (press enter to use your git config)" + : "Author email (optional)", + ...(env.authorEmail !== undefined && { defaultValue: env.authorEmail }), + placeholder: env.authorEmail ?? "jane@example.com", + }); + assertNotCancelled(emailAns); + const email = stringOrEmpty(emailAns); + + return { + name, + ...(url.length > 0 && { url }), + ...(email.length > 0 && { email }), + }; +} + +async function resolveDescription( + args: InitArgs, + interactive: boolean, + env: EnvironmentDefaults, +): Promise { + const flag = nonEmpty(args.description); + if (flag !== undefined) return flag; + if (!interactive) return env.description; + const answer = await clack.text({ + message: env.description + ? "Short description (press enter to use package.json#description)" + : "Short description (optional)", + ...(env.description !== undefined && { defaultValue: env.description }), + placeholder: env.description ?? "What does the plugin do?", + }); + assertNotCancelled(answer); + const value = stringOrEmpty(answer); + return value.length === 0 ? undefined : value; +} + +async function resolveRepo( + args: InitArgs, + interactive: boolean, + env: EnvironmentDefaults, +): Promise { + const flag = nonEmpty(args.repo); + if (flag !== undefined) return flag; + if (!interactive) return env.repo; + const answer = await clack.text({ + message: env.repo + ? "Source repository URL (press enter to use the detected origin)" + : "Source repository URL (optional)", + ...(env.repo !== undefined && { defaultValue: env.repo }), + placeholder: env.repo ?? "https://github.com/...", + validate: (raw) => { + const v = (raw ?? "").trim(); + if (v.length === 0) return undefined; + if (!v.startsWith("https://")) return "Must start with https://"; + return undefined; + }, + }); + assertNotCancelled(answer); + const value = stringOrEmpty(answer); + return value.length === 0 ? undefined : value; +} + +async function resolveSecurity(args: InitArgs, interactive: boolean) { + const flagEmail = nonEmpty(args["security-email"]); + const flagUrl = nonEmpty(args["security-url"]); + + if (flagEmail !== undefined || flagUrl !== undefined) { + return { + ...(flagEmail !== undefined && { email: flagEmail }), + ...(flagUrl !== undefined && { url: flagUrl }), + }; + } + if (!interactive) return undefined; + + const emailAns = await clack.text({ + message: "Security contact email (leave blank to provide a URL or fill in later)", + }); + assertNotCancelled(emailAns); + const email = stringOrEmpty(emailAns); + if (email.length > 0) return { email }; + + const urlAns = await clack.text({ + message: "Security contact URL (leave blank to fill in later)", + }); + assertNotCancelled(urlAns); + const url = stringOrEmpty(urlAns); + if (url.length === 0) return undefined; + return { url }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Session pre-fill +// ────────────────────────────────────────────────────────────────────────── + +/** + * The slice of the active session init cares about. Pulled out so the + * session-loading helper can return a plain shape without dragging the + * full StoredSession type through the rest of the command. + */ +interface SessionInfo { + did: string; + handle: string | null; +} + +/** + * Choose where to run environment probes against: + * + * - target dir if it exists (already a git repo with a package.json, + * scaffolding into it), + * - cwd otherwise (init creating a new sibling dir). + * + * Picking the target lets us read package.json#description / #license + * for the "scaffold into existing repo" case; falling back to cwd + * still gets us git user.name/user.email which live in the global + * config and don't depend on which dir we run from. + */ +async function pickProbeDir(targetDir: string): Promise { + const { stat } = await import("node:fs/promises"); + try { + const info = await stat(targetDir); + if (info.isDirectory()) return targetDir; + } catch { + // Target dir doesn't exist yet — that's the common case for + // `init my-plugin`. Fall through to cwd. + } + return process.cwd(); +} + +/** + * Load the active publisher session from the on-disk credentials store. + * Returns `undefined` on every failure path — the credentials file + * doesn't exist (fresh checkout), is corrupted, contains no current + * session, etc. init is reachable in all these states; we never want + * scaffolding to be blocked by a session lookup. + */ +async function loadCurrentSessionSilently(): Promise { + try { + const credentials = new FileCredentialStore(); + const current = await credentials.current(); + if (!current) return undefined; + return { did: current.did, handle: current.handle }; + } catch { + return undefined; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +/** + * Resolve `slug` and `targetDir` from the positional `name` + `--dir` + * combo. In all modes: + * + * - `init my-plugin` → slug="my-plugin", dir="./my-plugin" + * - `init my-plugin --dir foo` → slug="my-plugin", dir="./foo" + * - `init --dir foo` → slug=basename(foo), dir="./foo" + * - `init` → slug=basename(cwd), dir=cwd + */ +function resolveSlugAndDir(args: InitArgs): { slug: string; targetDir: string } { + const name = nonEmpty(args.name); + const dirArg = nonEmpty(args.dir); + if (name !== undefined) { + const slug = name; + const targetDir = dirArg !== undefined ? resolve(dirArg) : resolve(`./${slug}`); + return { slug, targetDir }; + } + const targetDir = dirArg !== undefined ? resolve(dirArg) : resolve("."); + const slug = basename(targetDir); + return { slug, targetDir }; +} + +function printNextSteps(targetDir: string, inputs: ScaffoldInputs, interactive: boolean): void { + const todos: string[] = []; + if (inputs.publisher === undefined) todos.push("publisher"); + if (inputs.author === undefined) todos.push("author"); + if (inputs.security === undefined) todos.push("security"); + + if (interactive) { + const lines: string[] = []; + if (todos.length > 0) { + lines.push( + `${pc.yellow("⚠")} Fill in the TODO placeholders in emdash-plugin.jsonc (${todos.join(", ")}) before bundling.`, + ); + } + lines.push(`1. ${pc.cyan(`cd ${targetDir}`)}`); + lines.push(`2. ${pc.cyan("pnpm install")}`); + lines.push(`3. ${pc.cyan("pnpm test")} confirm the scaffold passes its own test`); + lines.push(`4. Edit src/plugin.ts to add routes and hooks.`); + lines.push(`5. ${pc.cyan("emdash-plugin bundle")} when ready to publish`); + clack.note(lines.join("\n"), "Next steps"); + clack.outro(`Plugin ready at ${pc.bold(targetDir)}`); + return; + } + + consola.info(""); + consola.info("Next steps:"); + if (todos.length > 0) { + consola.info( + ` ${pc.yellow("!")} Fill in the TODO placeholders in ${pc.dim(`${targetDir}/emdash-plugin.jsonc`)} (${todos.join(", ")}) before bundling.`, + ); + } + consola.info(` 1. ${pc.cyan(`cd ${targetDir}`)}`); + consola.info(` 2. ${pc.cyan("pnpm install")}`); + consola.info(` 3. ${pc.cyan("pnpm test")} # confirm the scaffold passes its own test`); + consola.info(` 4. Edit ${pc.dim("src/plugin.ts")} to add routes and hooks.`); + consola.info(` 5. ${pc.cyan("emdash-plugin bundle")} # when ready to publish`); +} + +/** + * clack prompts return either the answer value or `Symbol.for("clack:cancel")` + * when the user hits Ctrl+C / ESC. We turn that into a clean cancel-and- + * exit rather than letting it propagate as an unrelated runtime error. + */ +function assertNotCancelled(value: unknown): void { + if (clack.isCancel(value)) { + clack.cancel("Cancelled."); + process.exit(0); + } +} + +/** + * Normalise clack's prompt return value to a trimmed string. `text()` + * returns `string | symbol`; the symbol case is handled separately by + * `assertNotCancelled`, so by the time this runs the value is either a + * string or something we treat as empty. + */ +function stringOrEmpty(value: unknown): string { + if (typeof value !== "string") return ""; + return value.trim(); +} + +/** + * Trim+empty-string treats `--flag=`, `--flag ""`, and an unprovided + * flag identically. citty leaves explicit empty strings as `""`; we + * normalise to `undefined` so downstream branching is uniform. + */ +function nonEmpty(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +/** + * Thrown for CLI-input validation failures (invalid slug, malformed + * publisher). Distinct from `InitError` (filesystem / conflict + * failures) so the outer dispatch can produce a different exit class + * if we ever add more granular codes. + */ +class InputError extends Error { + override readonly name = "InputError"; +} diff --git a/packages/registry-cli/src/commands/login.ts b/packages/plugin-cli/src/commands/login.ts similarity index 99% rename from packages/registry-cli/src/commands/login.ts rename to packages/plugin-cli/src/commands/login.ts index f924baa34..e7641d80d 100644 --- a/packages/registry-cli/src/commands/login.ts +++ b/packages/plugin-cli/src/commands/login.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry login ` + * `emdash-plugin login ` * * Interactive atproto OAuth login. Spins up a loopback HTTP server, opens the * user's browser at the AS authorization URL, awaits the callback, exchanges diff --git a/packages/registry-cli/src/commands/logout.ts b/packages/plugin-cli/src/commands/logout.ts similarity index 96% rename from packages/registry-cli/src/commands/logout.ts rename to packages/plugin-cli/src/commands/logout.ts index a4ef3c88f..a7bc9e3f4 100644 --- a/packages/registry-cli/src/commands/logout.ts +++ b/packages/plugin-cli/src/commands/logout.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry logout [--did ]` + * `emdash-plugin logout [--did ]` * * Revoke the active publisher session and remove its stored state. * diff --git a/packages/registry-cli/src/commands/publish.ts b/packages/plugin-cli/src/commands/publish.ts similarity index 93% rename from packages/registry-cli/src/commands/publish.ts rename to packages/plugin-cli/src/commands/publish.ts index 0e88eead5..d7de19aed 100644 --- a/packages/registry-cli/src/commands/publish.ts +++ b/packages/plugin-cli/src/commands/publish.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry publish --url ` + * `emdash-plugin publish --url ` * * Thin citty wrapper around `publishRelease` from `../publish/api.js`. * @@ -20,7 +20,7 @@ import { lookup as dnsLookup } from "node:dns/promises"; import { readFile, stat } from "node:fs/promises"; -import { resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import type { PluginManifest } from "@emdash-cms/plugin-types"; import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client"; @@ -120,7 +120,7 @@ export const publishCommand = defineCommand({ }, async run({ args }) { // In --json mode, stdout MUST contain only the final JSON object so - // callers can `emdash-registry publish ... --json | jq`. Route every + // callers can `emdash-plugin publish ... --json | jq`. Route every // consola log line to stderr; capture the previous reporter set so we // can restore in the finally below (matters when the CLI is exec'd // in-process by tests or wrappers). @@ -186,7 +186,7 @@ async function runPublish(args: PublishArgs): Promise { const session = await credentials.current(); if (!session) { throw new CliError( - "Not logged in. Run: emdash-registry login ", + "Not logged in. Run: emdash-plugin login ", 1, "NOT_LOGGED_IN", ); @@ -206,7 +206,7 @@ async function runPublish(args: PublishArgs): Promise { if (check.kind === "mismatch") { throw new CliError( `Manifest pins publisher to ${pc.bold(check.pinnedDisplay)} (${check.pinnedDid}), but the active session is ${session.did}. ` + - `Either switch sessions (\`emdash-registry switch ${check.pinnedDid}\`), or edit the manifest if you are transferring the plugin to a new publisher.`, + `Either switch sessions (\`emdash-plugin switch ${check.pinnedDid}\`), or edit the manifest if you are transferring the plugin to a new publisher.`, 1, "MANIFEST_PUBLISHER_MISMATCH", ); @@ -339,9 +339,7 @@ async function runPublish(args: PublishArgs): Promise { consola.info( `The aggregator will pick this up from the firehose. To verify discovery once it's indexed:`, ); - console.log( - ` ${pc.cyan(`emdash-registry info ${session.handle ?? session.did} ${result.slug}`)}`, - ); + console.log(` ${pc.cyan(`emdash-plugin info ${session.handle ?? session.did} ${result.slug}`)}`); } /** @@ -461,7 +459,22 @@ async function loadManifestBootstrap( const path = args.manifest ?? `./${MANIFEST_FILENAME}`; try { const { manifest, path: resolvedPath } = await loadManifest(path); - const normalised = normaliseManifest(manifest); + // Manifest `version` is optional; reconcile with + // `package.json#version` (the canonical source for + // npm-distributed plugins) before validating. + const packageVersion = await readSiblingPackageVersion(dirname(resolvedPath)); + let normalised: NormalisedManifest; + try { + normalised = normaliseManifest(manifest, packageVersion); + } catch (error) { + if (error instanceof Error && "code" in error) { + const code = (error as { code: unknown }).code; + if (code === "VERSION_MISSING" || code === "VERSION_MISMATCH") { + throw new CliError(error.message, 1, String(code)); + } + } + throw error; + } log.info(`Loaded manifest: ${pc.dim(resolvedPath)}`); return { path: resolvedPath, @@ -479,6 +492,61 @@ async function loadManifestBootstrap( } } +/** + * Read `package.json#version` from the directory containing the + * manifest. Returns `undefined` when no `package.json` exists (the + * registry-only path). Throws `CliError` when the file exists but is + * malformed, so a typo like `"verison"` surfaces directly rather than + * appearing as a misleading VERSION_MISSING further down. + */ +async function readSiblingPackageVersion(manifestDir: string): Promise { + const packageJsonPath = join(manifestDir, "package.json"); + let source: string; + try { + source = await readFile(packageJsonPath, "utf-8"); + } catch (error) { + // ENOENT is the registry-only path; surface anything else. + if ( + error instanceof Error && + "code" in error && + (error as { code: unknown }).code === "ENOENT" + ) { + return undefined; + } + throw new CliError( + `Failed to read package.json at ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`, + 1, + "PACKAGE_JSON_UNREADABLE", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch (error) { + throw new CliError( + `package.json at ${packageJsonPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + 1, + "PACKAGE_JSON_INVALID", + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError( + `package.json at ${packageJsonPath} must be a JSON object.`, + 1, + "PACKAGE_JSON_INVALID", + ); + } + const version = (parsed as { version?: unknown }).version; + if (version !== undefined && (typeof version !== "string" || version.length === 0)) { + throw new CliError( + `package.json at ${packageJsonPath} has a non-string \`version\` (${JSON.stringify(version)}).`, + 1, + "PACKAGE_JSON_INVALID", + ); + } + return typeof version === "string" ? version : undefined; +} + // ── helpers ────────────────────────────────────────────────────────────────── /** diff --git a/packages/registry-cli/src/commands/search.ts b/packages/plugin-cli/src/commands/search.ts similarity index 94% rename from packages/registry-cli/src/commands/search.ts rename to packages/plugin-cli/src/commands/search.ts index 46fb11c90..89bdb1299 100644 --- a/packages/registry-cli/src/commands/search.ts +++ b/packages/plugin-cli/src/commands/search.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry search [--capability ] [--limit ] [--cursor ]` + * `emdash-plugin search [--capability ] [--limit ] [--cursor ]` * * Free-text search the aggregator. Read-only; no auth required. */ @@ -82,7 +82,7 @@ export const searchCommand = defineCommand({ // 100, so suggesting "increase the limit" was misleading advice. consola.info( `More results available. Continue with: ${pc.cyan( - `emdash-registry search "${args.query}" --cursor ${result.cursor}`, + `emdash-plugin search "${args.query}" --cursor ${result.cursor}`, )}`, ); } diff --git a/packages/registry-cli/src/commands/switch.ts b/packages/plugin-cli/src/commands/switch.ts similarity index 87% rename from packages/registry-cli/src/commands/switch.ts rename to packages/plugin-cli/src/commands/switch.ts index 98c24cc9b..f777336fe 100644 --- a/packages/registry-cli/src/commands/switch.ts +++ b/packages/plugin-cli/src/commands/switch.ts @@ -1,9 +1,9 @@ /** - * `emdash-registry switch ` + * `emdash-plugin switch ` * * Change the active publisher session. The DID must already be in the * credentials store (i.e. you've previously logged in as it). Use - * `emdash-registry whoami` to see stored sessions. + * `emdash-plugin whoami` to see stored sessions. * * The OAuth library still resolves a refreshed access token by DID on the * next publish; this command only changes which DID is "current" for the @@ -38,7 +38,7 @@ export const switchCommand = defineCommand({ const target = await credentials.get(args.did); if (!target) { consola.error( - `No stored session for ${args.did}. Run: emdash-registry whoami to list stored sessions.`, + `No stored session for ${args.did}. Run: emdash-plugin whoami to list stored sessions.`, ); process.exit(1); } diff --git a/packages/registry-cli/src/commands/validate.ts b/packages/plugin-cli/src/commands/validate.ts similarity index 98% rename from packages/registry-cli/src/commands/validate.ts rename to packages/plugin-cli/src/commands/validate.ts index 09ef43072..a56b7bed0 100644 --- a/packages/registry-cli/src/commands/validate.ts +++ b/packages/plugin-cli/src/commands/validate.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry validate [path]` + * `emdash-plugin validate [path]` * * Validate an `emdash-plugin.jsonc` manifest against the v1 schema. * diff --git a/packages/registry-cli/src/commands/whoami.ts b/packages/plugin-cli/src/commands/whoami.ts similarity index 88% rename from packages/registry-cli/src/commands/whoami.ts rename to packages/plugin-cli/src/commands/whoami.ts index 81382fe3f..03eb7b964 100644 --- a/packages/registry-cli/src/commands/whoami.ts +++ b/packages/plugin-cli/src/commands/whoami.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry whoami` + * `emdash-plugin whoami` * * Show the active publisher session, plus a short list of any other stored * sessions. Read-only: this command never refreshes tokens or hits the network. @@ -37,7 +37,7 @@ export const whoamiCommand = defineCommand({ } if (!current) { - consola.info("Not logged in. Run: emdash-registry login "); + consola.info("Not logged in. Run: emdash-plugin login "); return; } @@ -53,7 +53,7 @@ export const whoamiCommand = defineCommand({ console.log(` ${pc.dim(s.handle ?? s.did)} (${pc.dim(s.did)})`); } console.log(); - consola.info(`Switch with: ${pc.cyan("emdash-registry switch ")}`); + consola.info(`Switch with: ${pc.cyan("emdash-plugin switch ")}`); } }, }); diff --git a/packages/registry-cli/src/config.ts b/packages/plugin-cli/src/config.ts similarity index 100% rename from packages/registry-cli/src/config.ts rename to packages/plugin-cli/src/config.ts diff --git a/packages/plugin-cli/src/dev/command.ts b/packages/plugin-cli/src/dev/command.ts new file mode 100644 index 000000000..4e1e002e5 --- /dev/null +++ b/packages/plugin-cli/src/dev/command.ts @@ -0,0 +1,236 @@ +/** + * `emdash-plugin dev` + * + * Watch mode wrapper around `buildPlugin`. Rebuilds the plugin + * whenever `src/**`, `emdash-plugin.jsonc`, or `package.json` change. + * + * Behaviour: + * + * - Logs a divider + timestamp + result per rebuild. Doesn't clear + * the screen — authors keep a scrollback of what happened. + * - On error, prints the BuildError's structured code + message. + * Does *not* wipe `dist/` — the last successful build stays on + * disk so a downstream site importing the plugin keeps working + * until the next successful rebuild. + * - Debounces rapid bursts (editors saving multiple files) at + * 150ms so a single edit doesn't trigger several rebuilds. + * - Serialises builds: if a change arrives while one is in flight, + * a follow-up build is queued and runs after the current one + * completes. This prevents a slow earlier build from overwriting + * dist/ with stale output after a newer build has already + * finished. + * - SIGINT (Ctrl-C) waits for any in-flight build before closing + * the watcher and exits 0. A second signal during shutdown + * forces immediate exit so an impatient Ctrl-Ctrl-C still works. + * + * Known limitation: the build pipeline's probe step dynamically + * imports the freshly-built plugin module to harvest hook/route names. + * Each probe goes to a unique temp file, and Node's ESM loader caches + * modules by URL with no eviction. Across many rebuilds the loader's + * cache grows monotonically (each leaked module is small — kilobytes — + * but the count is unbounded). Restart `dev` after long sessions; a + * future refactor will harvest the surface via AST instead of import(). + */ + +import { isAbsolute, relative, resolve, sep } from "node:path"; + +import { defineCommand } from "citty"; +import consola from "consola"; +import pc from "picocolors"; + +import { BuildError, buildPlugin, type BuildLogger } from "../build/api.js"; + +const DEBOUNCE_MS = 150; +/** + * Files / globs the watcher tracks for change events. Relative to the + * plugin directory; chokidar's `cwd` option resolves them. + */ +const WATCH_GLOBS = ["src/**", "emdash-plugin.jsonc", "package.json"]; + +export const devCommand = defineCommand({ + meta: { + name: "dev", + description: "Watch a sandboxed plugin's sources and rebuild on change", + }, + args: { + dir: { + type: "string", + description: "Plugin directory (default: current directory)", + default: process.cwd(), + }, + outDir: { + type: "string", + alias: "o", + description: "Output directory (default: ./dist)", + default: "dist", + }, + }, + async run({ args }) { + const { default: chokidar } = await import("chokidar"); + + // Lifted out so scheduleRebuild can short-circuit any + // post-Ctrl-C file events while the watcher is still draining. + let shutdownStarted = false; + + const logger: BuildLogger = { + start: (m) => consola.start(m), + info: (m) => consola.info(m), + success: (m) => consola.success(m), + warn: (m) => consola.warn(m), + }; + + // Serialisation state. `pending` holds the in-flight build's + // promise; `queued` is a single follow-up trigger label + // (collapsing multiple-rebuilds-during-build into one). When + // the current build settles, the queued trigger fires. + let pending: Promise | undefined; + let queuedTrigger: string | undefined; + + const runBuild = async (label: string): Promise => { + const stamp = new Date().toLocaleTimeString(); + console.log(); + console.log(pc.dim(`── ${label} at ${stamp} ─────────────────────`)); + try { + await buildPlugin({ + dir: args.dir, + outDir: args.outDir, + logger, + }); + } catch (error) { + if (error instanceof BuildError) { + consola.error(`${pc.bold(error.code)}: ${error.message}`); + } else { + consola.error(error instanceof Error ? error.message : String(error)); + } + consola.info( + pc.dim("Last successful build (if any) is still in dist/. Waiting for changes..."), + ); + } + }; + + /** + * Schedule a build. If one's running, queue the trigger so we + * rebuild after it finishes; the queued trigger is collapsed + * (only the most recent change label survives). Otherwise + * starts immediately and tracks the promise in `pending` so + * subsequent triggers know to queue. + */ + const startBuild = (label: string): void => { + if (pending) { + queuedTrigger = label; + return; + } + pending = (async () => { + // try/finally so state always clears even if a future + // runBuild throws past its own catch (e.g. logger + // write error during shutdown). A stuck `pending` + // would deadlock the watcher. + try { + let currentLabel = label; + while (currentLabel) { + await runBuild(currentLabel); + currentLabel = queuedTrigger ?? ""; + queuedTrigger = undefined; + } + } finally { + pending = undefined; + queuedTrigger = undefined; + } + })(); + }; + + // Initial build before starting the watcher. If it fails the + // watcher still starts so the author can fix the error and + // re-trigger. Run synchronously so the user sees the result + // before we print "Watching". + await runBuild("initial build"); + + // Resolve outDir relative to the plugin dir so the ignore + // pattern matches whatever the user passed for `--outDir`. + // chokidar wants forward-slash globs even on Windows, so + // normalise the platform separator (path.sep) to "/". + const resolvedOutDir = resolve(args.dir, args.outDir); + const cwdAbs = resolve(args.dir); + const outDirRel = relative(cwdAbs, resolvedOutDir); + // `outDirGlob` is the ignore pattern only when outDir is + // strictly inside the watched dir. `relative` returns "" when + // the two paths are equal (outDir === plugin root, which would + // be pathological — would write plugin.mjs / manifest.json + // next to src/ and the watcher would loop). Empty string and + // upward / absolute paths fall through to `undefined`. + const outDirGlob = + outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel) + ? `${outDirRel.split(sep).join("/")}/**` + : undefined; + + const ignored = ["**/node_modules/**", ...(outDirGlob ? [outDirGlob] : [])]; + + const watcher = chokidar.watch(WATCH_GLOBS, { + cwd: args.dir, + ignoreInitial: true, + ignored, + }); + + let timer: NodeJS.Timeout | undefined; + let pendingTrigger: string | undefined; + + const scheduleRebuild = (path: string) => { + if (shutdownStarted) return; + pendingTrigger = path; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + const trigger = pendingTrigger ?? "change"; + pendingTrigger = undefined; + timer = undefined; + startBuild(`rebuild (${trigger})`); + }, DEBOUNCE_MS); + }; + + watcher.on("add", scheduleRebuild); + watcher.on("change", scheduleRebuild); + watcher.on("unlink", scheduleRebuild); + watcher.on("error", (error) => { + consola.error(`Watcher error: ${error instanceof Error ? error.message : String(error)}`); + }); + + consola.info(`Watching ${pc.cyan(args.dir)} for changes (Ctrl-C to stop)`); + + // Shutdown waits for the in-flight build (if any) so the user + // doesn't end up with a torn dist/. A second SIGINT during + // shutdown forces immediate exit — impatience is a valid + // signal. + await new Promise((resolveOuter) => { + const shutdown = () => { + if (shutdownStarted) { + consola.warn("Second interrupt — forcing exit."); + process.exit(130); + } + shutdownStarted = true; + consola.info("Stopping watcher (waiting for in-flight build)..."); + if (timer) clearTimeout(timer); + queuedTrigger = undefined; + const drainAndClose = async () => { + // Close the watcher before draining `pending` so a + // file change arriving during the wait can't queue + // a new build the user already cancelled. + await watcher.close(); + if (pending) { + try { + await pending; + } catch { + /* runBuild swallows its own errors */ + } + } + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + resolveOuter(); + }; + void drainAndClose(); + }; + // Use `on` (not `once`) so a second signal during shutdown + // hits the same handler and the impatience branch fires. + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + }); + }, +}); diff --git a/packages/registry-cli/src/index.ts b/packages/plugin-cli/src/index.ts similarity index 81% rename from packages/registry-cli/src/index.ts rename to packages/plugin-cli/src/index.ts index e3c62c3f1..2d24a4a01 100644 --- a/packages/registry-cli/src/index.ts +++ b/packages/plugin-cli/src/index.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node /** - * @emdash-cms/registry-cli + * @emdash-cms/plugin-cli * - * CLI for the experimental EmDash plugin registry. Entry point: `emdash-registry`. + * CLI for the experimental EmDash plugin registry. Entry point: `emdash-plugin`. * * Subcommands: * - login — interactive atproto OAuth login @@ -11,6 +11,9 @@ * - switch — change the active publisher session * - search — free-text search the aggregator * - info — show details about a package + * - init — scaffold a new sandboxed plugin + * - build — produce the npm distribution artifacts (dist/index.mjs, dist/plugin.mjs, dist/manifest.json) + * - dev — watch sources and rebuild on change * - bundle — bundle a plugin source directory into a tarball * - publish — publish a release that points at a hosted tarball * - validate — validate an emdash-plugin.jsonc manifest against the v1 schema @@ -21,8 +24,10 @@ import { defineCommand, runMain } from "citty"; +import { buildCommand } from "./build/command.js"; import { bundleCommand } from "./bundle/command.js"; import { infoCommand } from "./commands/info.js"; +import { initCommand } from "./commands/init.js"; import { loginCommand } from "./commands/login.js"; import { logoutCommand } from "./commands/logout.js"; import { publishCommand } from "./commands/publish.js"; @@ -30,10 +35,11 @@ import { searchCommand } from "./commands/search.js"; import { switchCommand } from "./commands/switch.js"; import { validateCommand } from "./commands/validate.js"; import { whoamiCommand } from "./commands/whoami.js"; +import { devCommand } from "./dev/command.js"; const main = defineCommand({ meta: { - name: "emdash-registry", + name: "emdash-plugin", description: "CLI for the experimental EmDash plugin registry", }, subCommands: { @@ -43,6 +49,9 @@ const main = defineCommand({ switch: switchCommand, search: searchCommand, info: infoCommand, + init: initCommand, + build: buildCommand, + dev: devCommand, bundle: bundleCommand, publish: publishCommand, validate: validateCommand, diff --git a/packages/plugin-cli/src/init/environment.ts b/packages/plugin-cli/src/init/environment.ts new file mode 100644 index 000000000..8cebcc264 --- /dev/null +++ b/packages/plugin-cli/src/init/environment.ts @@ -0,0 +1,261 @@ +/** + * Environment-probe helpers for `emdash-plugin init`. + * + * The goal: when the user runs init, pre-fill prompts with whatever the + * surrounding environment already knows. None of these probes are + * authoritative — they're just sensible defaults the user can override. + * + * Sources, in priority order per field: + * + * - git config (user.name, user.email): the canonical "who am I" on + * any developer machine. + * - `git remote get-url origin`: the most reliable source for the + * plugin's repo URL. + * - package.json#description / #license / #repository: catches the + * "scaffolding into an existing repo skeleton" case. + * + * Every probe swallows errors. The CLI uses these as soft defaults; a + * missing git binary, a non-git target dir, an unreadable package.json + * are all expected and silently fall through to "no default". + */ + +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Hard cap on the bytes we'll read from package.json. Pre-fills are a + * convenience; we don't want to OOM on a deranged 1GB package.json that + * happens to live in the target directory. + */ +const PACKAGE_JSON_MAX_BYTES = 64 * 1024; + +/** + * Timeout for the git child-process calls. Local git config / remote + * lookup should complete in milliseconds; anything that hangs is + * almost certainly a misconfigured remote or a network mount. We'd + * rather skip the pre-fill than make `init` feel slow. + */ +const GIT_TIMEOUT_MS = 2_000; + +/** + * Snapshot of pre-fill values discovered from the surrounding + * environment. Every field is optional — undefined means "nothing + * found", and the caller should fall back to its own default. + */ +export interface EnvironmentDefaults { + authorName: string | undefined; + authorEmail: string | undefined; + license: string | undefined; + description: string | undefined; + repo: string | undefined; +} + +/** + * Probe the environment for pre-fillable values. Inspects: + * + * 1. git config (global and per-dir) for user.name + user.email. + * 2. `git remote get-url origin` (with normalization) for the repo URL. + * 3. `/package.json` for description / license / repository. + * + * Errors in any one probe don't abort the others. The function returns + * whatever it could determine, with each missing field as undefined. + */ +export async function probeEnvironment(targetDir: string): Promise { + // Run independent probes in parallel — they don't depend on each + // other and each one is the slow path of a single fs/exec call. + const [authorName, authorEmail, repoFromGit, fromPackageJson] = await Promise.all([ + gitConfig("user.name", targetDir), + gitConfig("user.email", targetDir), + gitRemoteUrl(targetDir), + readPackageJson(targetDir), + ]); + + return { + authorName, + authorEmail, + // package.json#repository overrides the git remote only if it + // looks like a deliberate, complete URL (the git remote is the + // stronger signal of "where this code actually lives", but if a + // pre-existing package.json points elsewhere, respect it). + repo: fromPackageJson.repo ?? repoFromGit, + license: fromPackageJson.license, + description: fromPackageJson.description, + }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Git config +// ────────────────────────────────────────────────────────────────────────── + +/** + * Read a git config value, falling back from the target dir to the + * global config. Returns undefined if git isn't installed, the dir + * isn't a repo, or the key is unset. + * + * We use `git config --get` rather than `git config --show-origin` etc. + * because `--get` is the simplest "give me the effective value" form + * and it walks the repo→global→system fallback chain itself. + */ +async function gitConfig(key: string, cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["config", "--get", key], { + cwd, + timeout: GIT_TIMEOUT_MS, + // Limit output size to defend against a deliberately-bizarre + // git config value. 4 KiB is generous for "name" / "email". + maxBuffer: 4096, + }); + const value = stdout.trim(); + return value.length === 0 ? undefined : value; + } catch { + return undefined; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Git remote +// ────────────────────────────────────────────────────────────────────────── + +const GIT_SSH_RE = /^git@([^:]+):(.+?)(?:\.git)?$/; +const HTTPS_TRAILING_GIT_RE = /\.git$/; +const GIT_URL_PREFIX_RE = /^git\+/; + +/** + * Read `origin` remote URL and normalize to https. `git@github.com:foo/bar.git` + * becomes `https://github.com/foo/bar`. Returns undefined if there's no + * origin remote, no git, or the URL doesn't normalize to a recognisable + * shape. + */ +async function gitRemoteUrl(cwd: string): Promise { + let raw: string; + try { + const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { + cwd, + timeout: GIT_TIMEOUT_MS, + // 1 KiB is plenty for a URL; protects against weird remote + // names that include the entire output of a hostile hook. + maxBuffer: 1024, + }); + raw = stdout.trim(); + } catch { + return undefined; + } + if (raw.length === 0) return undefined; + return normalizeRepoUrl(raw); +} + +/** + * Normalize a git remote URL to the https form the manifest's `repo` + * field expects. Handles: + * + * git@github.com:foo/bar.git → https://github.com/foo/bar + * git@github.com:foo/bar → https://github.com/foo/bar + * https://github.com/foo/bar.git → https://github.com/foo/bar + * https://github.com/foo/bar → https://github.com/foo/bar + * ssh://git@... → undefined (not auto-rewritten; user can paste) + * + * Returns undefined for shapes we don't recognise; the manifest schema + * requires `https://...` and we'd rather omit the pre-fill than write + * a value the schema will reject. + */ +function normalizeRepoUrl(raw: string): string | undefined { + const sshMatch = GIT_SSH_RE.exec(raw); + if (sshMatch) { + const [, host, path] = sshMatch; + return `https://${host}/${path}`; + } + if (raw.startsWith("https://")) { + return raw.replace(HTTPS_TRAILING_GIT_RE, ""); + } + return undefined; +} + +// ────────────────────────────────────────────────────────────────────────── +// package.json +// ────────────────────────────────────────────────────────────────────────── + +interface PackageJsonDefaults { + license: string | undefined; + description: string | undefined; + repo: string | undefined; +} + +const EMPTY_PACKAGE_JSON: PackageJsonDefaults = { + license: undefined, + description: undefined, + repo: undefined, +}; + +/** + * Read `/package.json` and pull license, description, and + * a normalized repo URL out of it. Returns the empty defaults shape on + * any failure (missing file, parse error, oversized file). The + * scaffolder never trusts this output directly — values flow through + * `EnvironmentDefaults` which the caller may or may not use. + */ +async function readPackageJson(targetDir: string): Promise { + const path = join(targetDir, "package.json"); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + return EMPTY_PACKAGE_JSON; + } + if (raw.length > PACKAGE_JSON_MAX_BYTES) { + return EMPTY_PACKAGE_JSON; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return EMPTY_PACKAGE_JSON; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return EMPTY_PACKAGE_JSON; + } + // JSON.parse returns `unknown`; the runtime check above narrows to + // "non-array object", and the cast just makes the property accesses + // below readable. The properties are still typed as `unknown` and + // each one gets its own runtime check. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above + const pkg = parsed as Record; + + const license = typeof pkg.license === "string" ? pkg.license.trim() : undefined; + const description = typeof pkg.description === "string" ? pkg.description.trim() : undefined; + const repo = repoFromPackageJsonRepository(pkg.repository); + + return { + license: license && license.length > 0 ? license : undefined, + description: description && description.length > 0 ? description : undefined, + repo, + }; +} + +/** + * Pull a repo URL out of package.json#repository. Handles both forms: + * + * "repository": "https://github.com/foo/bar" + * "repository": { "type": "git", "url": "git+https://github.com/foo/bar.git" } + * + * Normalizes through `normalizeRepoUrl`. Returns undefined if the + * value isn't a recognisable string or object-with-url. + */ +function repoFromPackageJsonRepository(value: unknown): string | undefined { + let raw: string | undefined; + if (typeof value === "string") { + raw = value; + } else if (value && typeof value === "object" && !Array.isArray(value)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above + const url = (value as Record).url; + if (typeof url === "string") raw = url; + } + if (raw === undefined || raw.length === 0) return undefined; + // npm accepts a leading `git+` on the URL (e.g. `git+https://...`). + // Strip it before normalisation so the https-check passes. + const stripped = raw.replace(GIT_URL_PREFIX_RE, ""); + return normalizeRepoUrl(stripped); +} diff --git a/packages/plugin-cli/src/init/scaffold.ts b/packages/plugin-cli/src/init/scaffold.ts new file mode 100644 index 000000000..96f6cabe2 --- /dev/null +++ b/packages/plugin-cli/src/init/scaffold.ts @@ -0,0 +1,156 @@ +/** + * Filesystem half of `emdash-plugin init`. Takes the scaffold inputs + + * the target directory and writes the file tree. Pure templates live in + * `./templates.ts` so this module is just policy: which files exist, + * where they go, what happens when something's already there. + * + * Overwrite policy: refuses by default if any target file exists. Pass + * `--force` to allow overwriting (file-by-file, not directory-wide). + * This avoids the common "I ran init in the wrong dir and clobbered my + * package.json" surprise. + */ + +import { access, mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +import { + renderGitignore, + renderManifest, + renderPackageJson, + renderPluginEntry, + renderReadme, + renderTest, + renderTsconfig, + type ScaffoldInputs, +} from "./templates.js"; + +export type InitErrorCode = "TARGET_FILE_EXISTS" | "INVALID_SLUG" | "INVALID_PUBLISHER"; + +export class InitError extends Error { + override readonly name = "InitError"; + readonly code: InitErrorCode; + /** When set, the list of paths that already exist and would be overwritten. */ + readonly conflicts: string[]; + + constructor(code: InitErrorCode, message: string, conflicts: string[] = []) { + super(message); + this.code = code; + this.conflicts = conflicts; + } +} + +export interface ScaffoldOptions { + /** Absolute path to the target directory. Created if it doesn't exist. */ + targetDir: string; + /** Validated scaffold inputs. */ + inputs: ScaffoldInputs; + /** + * When true, overwrite existing files. When false, refuse with + * `TARGET_FILE_EXISTS` listing the conflicting paths. + */ + force: boolean; + /** Optional callback per file written, for CLI progress output. */ + onFileWritten?: (relativePath: string) => void; +} + +export interface ScaffoldResult { + /** Absolute paths of every file the scaffolder wrote. */ + written: string[]; +} + +/** + * The file tree the scaffolder produces. Order matters: parents must + * appear before children (the writer creates intermediate dirs from + * the file path, so order is informational rather than mandatory, but + * a consistent order keeps the per-file progress output predictable). + */ +const FILES = [ + "emdash-plugin.jsonc", + "package.json", + "tsconfig.json", + ".gitignore", + "README.md", + "src/plugin.ts", + "tests/plugin.test.ts", +] as const; + +type ScaffoldFile = (typeof FILES)[number]; + +/** + * Scaffold a plugin into `targetDir`. The target dir is created if it + * doesn't exist; missing intermediate directories under it are created + * per-file as needed. + * + * If any target file already exists and `force` is false, the function + * throws BEFORE writing anything. Partial writes don't happen — either + * every file gets written or none do. + */ +export async function scaffold(options: ScaffoldOptions): Promise { + const { targetDir, inputs, force, onFileWritten } = options; + const absDir = resolve(targetDir); + + // Pre-flight: check for conflicts. We do this before any write so a + // partial scaffold can't leave the target dir in a half-broken state. + if (!force) { + const conflicts: string[] = []; + for (const file of FILES) { + const absPath = join(absDir, file); + if (await exists(absPath)) { + conflicts.push(file); + } + } + if (conflicts.length > 0) { + throw new InitError( + "TARGET_FILE_EXISTS", + `Cannot scaffold into ${absDir}: the following files already exist. Pass --force to overwrite them.\n ${conflicts.join("\n ")}`, + conflicts, + ); + } + } + + await mkdir(absDir, { recursive: true }); + + const written: string[] = []; + for (const file of FILES) { + const absPath = join(absDir, file); + await mkdir(dirname(absPath), { recursive: true }); + await writeFile(absPath, renderFile(file, inputs), "utf8"); + written.push(absPath); + onFileWritten?.(file); + } + + return { written }; +} + +/** + * Dispatch each scaffold-file path to its renderer. Centralised here so + * adding a new file (icon, screenshot stub, docs page) is one place to + * update — append to FILES, add a case. + */ +function renderFile(file: ScaffoldFile, inputs: ScaffoldInputs): string { + switch (file) { + case "emdash-plugin.jsonc": + return renderManifest(inputs); + case "package.json": + return renderPackageJson(inputs); + case "tsconfig.json": + return renderTsconfig(); + case ".gitignore": + return renderGitignore(); + case "README.md": + return renderReadme(inputs); + case "src/plugin.ts": + return renderPluginEntry(); + case "tests/plugin.test.ts": + return renderTest(); + } +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/packages/plugin-cli/src/init/templates.ts b/packages/plugin-cli/src/init/templates.ts new file mode 100644 index 000000000..83d1ace65 --- /dev/null +++ b/packages/plugin-cli/src/init/templates.ts @@ -0,0 +1,396 @@ +/** + * Pure file-content producers for `emdash-plugin init`. + * + * No filesystem access here — each function takes the inputs and returns + * the bytes that should land at the target path. Keeping these as pure + * functions makes the scaffolder testable without touching disk and + * keeps every template inspectable in one place. + * + * The shape produced is the authoring contract: + * + * emdash-plugin.jsonc — identity + trust contract + profile + * src/plugin.ts — `{ routes?, hooks? } satisfies SandboxedPlugin` + * package.json — type:module, devDep on @emdash-cms/plugin-cli + * tsconfig.json — strict, standalone + * .gitignore + * README.md + * tests/plugin.test.ts + * + * No `src/index.ts`, no `dist/` in source control. `emdash-plugin build` + * generates `dist/` artefacts (plugin.mjs, manifest.json, index.mjs). + */ + +import type { ManifestAuthor, ManifestSecurityContact } from "../manifest/schema.js"; + +/** + * Inputs to the scaffolder. + * + * Every field except `slug` is optional. Missing fields produce a + * placeholder in the generated manifest — either a `TODO:` line + * comment marking a value the author must fill in before the plugin + * works, or an outright omission of an optional field that the + * schema doesn't require. + * + * The contract: a scaffold produced from `{ slug }` alone is a valid + * starting point that the author can `cd` into, fix the TODOs in, + * and ship. There are no "init failed because you didn't pass enough + * flags" surprises. + */ +export interface ScaffoldInputs { + /** Plugin slug. Used as the directory name and the `slug` field. */ + slug: string; + /** + * Pre-filled publisher DID (resolved from a handle if the user + * typed one). When undefined, the manifest carries a TODO comment + * and an empty string; the author must set this before the plugin + * will load. + * + * The runtime only ever compares DIDs, so we write a DID — even if + * the user typed a handle. The handle, when known, is emitted as a + * `// ` line comment next to the pinned DID via + * `publisherHandle` below. + */ + publisher: string | undefined; + /** + * Optional handle that resolved to the `publisher` DID. Rendered as + * a `// ` line comment next to the pinned DID so a `git + * diff` reviewer sees a human-readable name for the publisher. The + * CLI ignores the comment on subsequent reads — only the DID is + * authoritative. + */ + publisherHandle: string | undefined; + /** SPDX license expression. Defaults to "MIT" when undefined. */ + license: string | undefined; + /** + * Author block. When undefined, the manifest carries a TODO + * comment and a placeholder name; author.url and author.email + * are omitted from the output entirely (the schema makes them + * optional). + */ + author: ManifestAuthor | undefined; + /** + * Security contact. When undefined, the manifest carries a TODO + * comment and a placeholder email; the author replaces it with + * a real contact before publishing. + */ + security: ManifestSecurityContact | undefined; + /** Optional short description. Omitted from the manifest when undefined. */ + description: string | undefined; + /** Optional repo URL. Omitted from the manifest when undefined. */ + repo: string | undefined; +} + +/** + * `emdash-plugin.jsonc` — the manifest. Includes a `$schema` pointer + * for editor completion. JSONC: tab-indented, no trailing comma on the + * final field. Fields omitted when the input is empty so the generated + * file is the smallest valid manifest, not a sea of `""`. + */ +export function renderManifest(input: ScaffoldInputs): string { + const lines: string[] = []; + lines.push("{"); + lines.push( + '\t"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",', + ); + lines.push(""); + lines.push(`\t"slug": ${jsonString(input.slug)},`); + // `version` deliberately omitted — the build reads it from + // `package.json` so there's a single source of truth. Registry-only + // plugins (no package.json) would set it here, but the scaffold + // always emits one. + + if (!input.publisher) { + lines.push( + '\t// TODO: set your atproto handle (e.g. "example.com") or DID before running `emdash-plugin bundle` or any local-dev integration. The plugin cannot load without it.', + ); + lines.push('\t"publisher": "",'); + } else { + // When we know the handle that resolved to this DID, append it + // as a line comment for `git diff` readability. The handle is + // purely informational — the CLI never reads it back. + const trailer = input.publisherHandle ? ` // ${input.publisherHandle}` : ""; + lines.push(`\t"publisher": ${jsonString(input.publisher)},${trailer}`); + } + + lines.push(""); + lines.push(`\t"license": ${jsonString(input.license ?? "MIT")},`); + + if (input.author) { + lines.push(`\t"author": ${renderAuthor(input.author)},`); + } else { + lines.push( + "\t// TODO: replace the placeholder with your real name and (optionally) url/email before publishing.", + ); + lines.push( + `\t"author": { "name": ${jsonString(`TODO: replace with your name (${input.slug} author)`)} },`, + ); + } + + if (input.security) { + lines.push(`\t"security": ${renderSecurityContact(input.security)},`); + } else { + lines.push( + "\t// TODO: replace the placeholder with a real security contact email or url before publishing. The lexicon mandates at least one.", + ); + lines.push('\t"security": { "email": "TODO@example.com" },'); + } + + if (input.description) { + lines.push(`\t"description": ${jsonString(input.description)},`); + } + if (input.repo) { + lines.push(`\t"repo": ${jsonString(input.repo)},`); + } + + lines.push(""); + lines.push("\t// Trust contract — what runtime APIs the plugin asks for."); + lines.push("\t// Empty arrays mean no extra privileges beyond logging,"); + lines.push("\t// KV, and route/hook registration. Changing these between"); + lines.push("\t// releases requires a version bump because installed"); + lines.push("\t// users have consented to the old contract."); + lines.push('\t"capabilities": [],'); + lines.push('\t"allowedHosts": [],'); + lines.push('\t"storage": {}'); + lines.push("}"); + lines.push(""); + return lines.join("\n"); +} + +/** + * Render a single author object as a JSONC inline value. Always + * single-line so the generated manifest stays compact. + */ +function renderAuthor(author: ManifestAuthor): string { + const parts: string[] = [`"name": ${jsonString(author.name)}`]; + if (author.url) parts.push(`"url": ${jsonString(author.url)}`); + if (author.email) parts.push(`"email": ${jsonString(author.email)}`); + return `{ ${parts.join(", ")} }`; +} + +/** + * Render a single security contact as a JSONC inline value. + */ +function renderSecurityContact(contact: ManifestSecurityContact): string { + const parts: string[] = []; + if (contact.email) parts.push(`"email": ${jsonString(contact.email)}`); + if (contact.url) parts.push(`"url": ${jsonString(contact.url)}`); + return `{ ${parts.join(", ")} }`; +} + +/** + * `src/plugin.ts` — runtime code. One route, no hooks. Demonstrates the + * two primitives a sandboxed plugin author needs: the strict + * `SandboxedPlugin` type (which infers handler signatures per hook / + * route name) and a default-exported `{ hooks?, routes? }` object. + */ +export function renderPluginEntry(): string { + return `import type { SandboxedPlugin } from "emdash/plugin"; + +/** + * Sandboxed plugin entry. The default export is a bare object; the + * \`satisfies SandboxedPlugin\` annotation gives TypeScript per-hook / + * per-route inference (\`ctx\` is \`PluginContext\` automatically; hook + * \`event\` parameters are typed by hook name). + */ +export default { +\troutes: { +\t\thello: { +\t\t\thandler: async (_routeCtx, ctx) => { +\t\t\t\tctx.log.info("hello route called", { pluginId: ctx.plugin.id }); +\t\t\t\treturn { greeting: "hello", pluginId: ctx.plugin.id }; +\t\t\t}, +\t\t}, +\t}, +} satisfies SandboxedPlugin; +`; +} + +/** + * `package.json` — npm-shape so the plugin is `pnpm add`-able. The + * scaffold sets `private: true` defensively; flip it off when you're + * ready to publish to npm. `version` here is the single source of + * truth — the build reads it and writes it into the bundled manifest. + * + * `./sandbox` export points at the built runtime bytes that both + * in-process and isolate loaders consume. `main` / `import` point at + * the auto-generated descriptor module the integration imports for + * default in `astro.config.mjs`. + */ +export function renderPackageJson(input: ScaffoldInputs): string { + const pkg = { + name: input.slug, + version: "0.1.0", + private: true, + type: "module", + main: "dist/index.mjs", + exports: { + ".": { + import: "./dist/index.mjs", + types: "./dist/index.d.mts", + }, + "./sandbox": "./dist/plugin.mjs", + }, + files: ["dist", "emdash-plugin.jsonc"], + scripts: { + build: "emdash-plugin build", + dev: "emdash-plugin dev", + typecheck: "tsc --noEmit", + test: "vitest run", + }, + peerDependencies: { + emdash: ">=0.12.0", + }, + devDependencies: { + "@emdash-cms/plugin-cli": ">=0.1.0", + emdash: ">=0.12.0", + typescript: "^5.9.0", + vitest: "^4.1.0", + }, + }; + return `${JSON.stringify(pkg, null, "\t")}\n`; +} + +/** + * `tsconfig.json` — strict, ES2022, bundler resolution. Mirrors the + * `node22 + bundler` style the rest of the EmDash workspace uses, but + * doesn't extend anything from the workspace so the scaffold is + * self-contained. + */ +export function renderTsconfig(): string { + const config = { + compilerOptions: { + target: "ES2022", + module: "preserve", + moduleResolution: "bundler", + strict: true, + esModuleInterop: true, + verbatimModuleSyntax: true, + skipLibCheck: true, + types: [], + }, + include: ["src/**/*", "tests/**/*"], + exclude: ["node_modules"], + }; + return `${JSON.stringify(config, null, "\t")}\n`; +} + +/** + * `.gitignore` — node_modules + dist (build output should not be + * committed; rebuild on every install). + */ +export function renderGitignore(): string { + return "node_modules/\ndist/\n"; +} + +/** + * `README.md` — three sections: develop, publish, version-bump rules. + * Nothing else. The author can extend; the scaffold doesn't pre-write + * marketing copy. + */ +export function renderReadme(input: ScaffoldInputs): string { + // The slug is the package title in headings + the import specifier, + // but it can contain hyphens (e.g. `my-plugin`) which aren't legal + // JS identifiers. Derive a camelCase binding name for the import + + // integration call. + const title = input.slug; + const importBinding = toCamelCase(input.slug); + return `# ${title} + +A sandboxed plugin for [EmDash CMS](https://emdashcms.com). + +## Develop + +\`\`\`sh +pnpm install +pnpm typecheck +pnpm test +\`\`\` + +To test against a running EmDash site, run \`pnpm dev\` in this +directory (rebuilds on save) and \`pnpm add file:../path/to/this\` +in the site. Then \`import ${importBinding} from "${input.slug}"\` and pass +it into \`emdash({ sandboxed: [${importBinding}] })\`. + +## Publish + +\`\`\`sh +emdash-plugin login # if you're not already logged in +emdash-plugin bundle # produces dist/${title}-.tar.gz +# upload that tarball to a public URL, then: +emdash-plugin publish --url https://your-host/... +\`\`\` + +## Version bumps + +Bump \`version\` in \`package.json\` when you ship a release. The +scaffold's \`emdash-plugin.jsonc\` deliberately omits \`version\` — +the build pipeline reads it from \`package.json\` so there's a single +source of truth. **Bump major** for breaking changes, **bump minor** +for new routes or hooks, **bump patch** for fixes. + +You MUST bump version whenever you change \`capabilities\`, \`allowedHosts\`, +or \`storage\` in the manifest. Installed users have consented to the +old trust contract; a change without a version bump would let new +behaviour slip past consent. +`; +} + +/** + * `tests/plugin.test.ts` — one passing test that exercises the + * hello route. Uses a minimal stubbed PluginContext rather than + * pulling in the runtime: the test asserts the handler returns the + * expected shape, not that the runtime wires it up correctly. + */ +export function renderTest(): string { + return `import { describe, expect, it } from "vitest"; + +import plugin from "../src/plugin.js"; + +describe("hello route", () => { +\tit("returns a greeting", async () => { +\t\tconst handler = plugin.routes?.hello; +\t\tif (!handler || typeof handler !== "object" || !("handler" in handler)) { +\t\t\tthrow new Error("hello route handler not found"); +\t\t} +\t\tconst result = await handler.handler({} as never, makeTestContext()); +\t\texpect(result).toEqual({ greeting: "hello", pluginId: "test-plugin" }); +\t}); +}); + +function makeTestContext() { +\t// Minimal stub PluginContext: the hello route only reads +\t// \`ctx.log.info\` and \`ctx.plugin.id\`. Real PluginContext has many +\t// more methods; add them as your plugin grows. +\treturn { +\t\tplugin: { id: "test-plugin", version: "0.1.0" }, +\t\tlog: { +\t\t\tinfo: () => {}, +\t\t\twarn: () => {}, +\t\t\terror: () => {}, +\t\t\tdebug: () => {}, +\t\t}, +\t} as unknown as import("emdash").PluginContext; +} +`; +} + +/** + * JSON-stringify a string with double quotes and proper escaping. + * Trivially `JSON.stringify` does the job, but wrapping it gives us + * a single place to switch quote styles or escape behaviour later. + */ +function jsonString(value: string): string { + return JSON.stringify(value); +} + +const SLUG_SEPARATOR_RE = /[-_]([a-z0-9])/g; + +/** + * Convert a plugin slug (`my-plugin`, `my_plugin`) into a JS identifier + * for use as an import binding. Slugs are validated to start with a + * letter (see `PLUGIN_SLUG_RE`), so the result is always a legal + * identifier. + */ +function toCamelCase(slug: string): string { + return slug.replace(SLUG_SEPARATOR_RE, (_, ch: string) => ch.toUpperCase()); +} diff --git a/packages/registry-cli/src/manifest/load.ts b/packages/plugin-cli/src/manifest/load.ts similarity index 99% rename from packages/registry-cli/src/manifest/load.ts rename to packages/plugin-cli/src/manifest/load.ts index fe3ad6541..32128dda7 100644 --- a/packages/registry-cli/src/manifest/load.ts +++ b/packages/plugin-cli/src/manifest/load.ts @@ -15,7 +15,7 @@ * value's location in the source where possible. * * The line/column mapping is critical for editor-side workflows: a user - * running `emdash-registry validate` from a CI step wants the same kind of + * running `emdash-plugin validate` from a CI step wants the same kind of * pointer they'd get from `tsc` or `eslint`, not a Zod issue tree. */ @@ -139,7 +139,7 @@ async function readBoundedUtf8(filePath: string): Promise { if (isNodeNotFoundError(error)) { throw new ManifestError( "MANIFEST_NOT_FOUND", - `No manifest at ${filePath}. Create one with: emdash-registry init`, + `No manifest at ${filePath}. Create one with: emdash-plugin init`, filePath, ); } diff --git a/packages/registry-cli/src/manifest/publisher.ts b/packages/plugin-cli/src/manifest/publisher.ts similarity index 98% rename from packages/registry-cli/src/manifest/publisher.ts rename to packages/plugin-cli/src/manifest/publisher.ts index 06de6b181..65cce4344 100644 --- a/packages/registry-cli/src/manifest/publisher.ts +++ b/packages/plugin-cli/src/manifest/publisher.ts @@ -6,7 +6,7 @@ * * 1. Manifest pins a publisher (DID or handle). * - DID: compare verbatim against the session DID. Mismatch is an - * immediate, no-override error. The user must `emdash-registry switch` + * immediate, no-override error. The user must `emdash-plugin switch` * to the right session, or edit the manifest if they're transferring * the plugin. * - Handle: resolve to a DID via `@atcute/identity-resolver`, then @@ -113,8 +113,12 @@ export async function checkPublisher(input: { * Resolve an atproto handle to a DID via the same actor-resolver the * OAuth flow uses (DoH + .well-known). Surfaces resolution failures * with a clear hint pointing the user at the DID-pin escape hatch. + * + * Exported so the `init` command can resolve a handle the user typed + * (or pulled from their active session) before writing it to the + * manifest — same primitive, same failure mode, same error code. */ -async function resolveHandleToDid(handle: Handle): Promise { +export async function resolveHandleToDid(handle: Handle): Promise { const resolver = createActorResolver(); try { const resolved = await resolver.resolve(handle); diff --git a/packages/plugin-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts new file mode 100644 index 000000000..ca4b087db --- /dev/null +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -0,0 +1,708 @@ +/** + * Zod schema for `emdash-plugin.jsonc` — the publisher-authored manifest that + * sits next to a plugin's source and feeds the registry CLI's `publish`, + * `validate`, and `init` commands. + * + * Relationship to the lexicon + * --------------------------- + * + * This schema is NOT the lexicon. The lexicon + * (`com.emdashcms.experimental.package.profile`) is the on-wire atproto + * record format, optimised for content-addressed storage and aggregator + * indexing. This schema is the authoring format, optimised for a human + * editing a file in VS Code with `$schema`-powered IDE completion. + * + * Fields that exist in BOTH places use the lexicon's field names verbatim + * (`license`, `keywords`, `repo`, `name`, `description`). Fields that the + * publisher cannot reasonably write by hand are derived at publish time and + * do not appear here: `id` (full AT URI requires the publisher's DID), + * `type` (always `"emdash-plugin"` from this CLI), `slug` (derived from the + * bundled `manifest.json`'s `id`), `lastUpdated` (set at publish time), + * `artifacts.package` (filled in from the fetched tarball), `extensions` + * (computed from the bundled manifest's capabilities + allowedHosts). + * + * The translation step lives in `./translate.ts`. + * + * Single-vs-multi-author convenience + * ---------------------------------- + * + * The lexicon stores `authors` and `security` as arrays. The overwhelmingly + * common case is one author and one security contact, so the manifest + * accepts both shapes: + * + * // single-author + * { "author": { "name": "Jane Doe" }, "security": { "email": "..." } } + * + * // multi-author + * { "authors": [{ "name": "..." }, { "name": "..." }], + * "securityContacts": [{ "email": "..." }] } + * + * `loadManifest` normalises both forms to the array shape before passing to + * publish. You can't mix forms for the same field (e.g. `author` AND + * `authors`); the schema rejects that. + * + * Strict mode + * ----------- + * + * Unknown keys are rejected with `.strict()`. This catches typos like + * `"licens": "MIT"` rather than letting them silently fall through. The + * tradeoff is that adding a field requires a CLI release; we accept that + * cost for v1 and may revisit after one cycle of field-add (issue #1029). + */ + +import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import { + CAPABILITY_RENAMES, + isDeprecatedCapability, + normalizeCapability, + PLUGIN_SLUG_MAX_LENGTH, + PLUGIN_SLUG_RE, + PLUGIN_VERSION_MAX_LENGTH, + PLUGIN_VERSION_RE, +} from "@emdash-cms/plugin-types"; +import { z } from "zod"; + +// ────────────────────────────────────────────────────────────────────────── +// Field-level schemas — exported so tests can target individual rules. +// +// Each field uses `.meta({ description })` so the descriptions flow into +// the generated JSON Schema and surface as inline hover hints in editors +// that support `$schema`-driven completion (VS Code, IntelliJ). +// ────────────────────────────────────────────────────────────────────────── + +/** + * SPDX license expression. The lexicon caps this at 256 chars. We don't + * validate the SPDX grammar here — the registry aggregator does that and + * gives clearer errors. We DO refuse the empty string and obvious garbage + * (whitespace-only) so the publish command can surface a useful message + * before any network round-trip. + */ +export const LicenseSchema = z + .string() + .min(1, 'license must be a non-empty SPDX expression (e.g. "MIT")') + .max(256, "license must be <= 256 characters (SPDX expressions are short)") + .refine((v) => v.trim().length > 0, "license cannot be whitespace-only") + .meta({ + title: "License", + description: + 'SPDX license expression (e.g. "MIT", "Apache-2.0", "MIT OR Apache-2.0"). Required on first publish; ignored on subsequent publishes (the existing profile wins).', + examples: ["MIT", "Apache-2.0", "MIT OR Apache-2.0"], + }); + +/** + * One author. Mirrors `profile.json#author`. The lexicon says authors + * "SHOULD specify at least one of url or email"; we don't enforce that + * here because anonymous-but-named authors are a legitimate (if + * discouraged) shape. The publish command surfaces it as a warning. + */ +export const AuthorSchema = z + .object({ + name: z + .string() + .min(1, "author.name cannot be empty") + .max(256, "author.name must be <= 256 characters") + .meta({ description: "Display name." }), + url: z + .string() + .url("author.url must be a valid URL") + .max(1024, "author.url must be <= 1024 characters") + .meta({ + description: "Author's homepage or profile URL. Either this or `email` is recommended.", + }) + .optional(), + email: z + .string() + .email("author.email must be a valid email") + .max(256, "author.email must be <= 256 characters") + .meta({ description: "Author's contact email. Either this or `url` is recommended." }) + .optional(), + }) + .strict() + .meta({ + title: "Author", + description: "A single author entry. Mirrors the lexicon's author shape.", + }); + +/** + * One security contact. Mirrors `profile.json#contact`. The lexicon + * mandates "at least one of url or email MUST be present"; Lexicon JSON + * can't express "required one-of", so we enforce it here. Without this + * check a publisher could write `{ "security": {} }` and the publish + * record would carry an empty contact (which aggregators reject anyway, + * but failing here is a better user experience). + */ +export const SecurityContactSchema = z + .object({ + url: z + .string() + .url("security.url must be a valid URL") + .max(1024, "security.url must be <= 1024 characters") + .meta({ + description: + "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required.", + }) + .optional(), + email: z + .string() + .email("security.email must be a valid email") + .max(256, "security.email must be <= 256 characters") + .meta({ + description: "Security contact email. Either this or `url` is required.", + }) + .optional(), + }) + .strict() + .refine( + (v) => v.url !== undefined || v.email !== undefined, + "security contact must have at least one of `url` or `email`", + ) + .meta({ + title: "Security contact", + description: "A single security contact. At least one of `url` or `email` must be present.", + }); + +/** + * Publisher identity, used to verify the active session matches the + * manifest's pinned publisher at publish time. Accepts a DID or a handle. + * + * Recommended form: DID (`did:plc:...`). DIDs are durable — they survive + * handle changes. Handles are friendlier to read but mutable: if the + * publisher's handle changes, the manifest needs an update. + * + * Omitted on first publish, the CLI writes the active session's DID + * back into the manifest automatically. Subsequent publishes verify + * against the pinned value. + * + * Validation is structural only here: DID syntax (`did:method:id`) or + * handle syntax (`name.tld`). The actual resolve-to-DID step happens at + * publish time via `@atcute/identity-resolver`. + */ +export const PublisherSchema = z + .string() + .refine( + (v) => isDid(v) || isHandle(v), + 'publisher must be an atproto DID (e.g. "did:plc:abc123") or handle (e.g. "example.com")', + ) + .meta({ + title: "Publisher", + description: + "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", + examples: ["did:plc:abc123def456", "example.com"], + }); + +/** Optional human-readable display name. Mirrors `profile.json#name`. */ +export const NameSchema = z + .string() + .min(1, "name cannot be empty when set") + .max(1024, "name must be <= 1024 characters") + .meta({ + title: "Display name", + description: + "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted.", + }); + +/** Short description. Mirrors `profile.json#description`. */ +export const DescriptionSchema = z + .string() + .min(1, "description cannot be empty when set") + .max(1024, "description must be <= 1024 characters") + .meta({ + title: "Description", + description: + "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists.", + }); + +/** Search keywords. Mirrors `profile.json#keywords`. */ +export const KeywordsSchema = z + .array( + z.string().min(1, "keyword cannot be empty").max(128, "each keyword must be <= 128 characters"), + ) + .max(5, "keywords array must have <= 5 entries (FAIR convention)") + .meta({ + title: "Keywords", + description: "Search keywords (<= 5 entries, FAIR convention).", + }); + +/** + * Source repository URL. Mirrors `release.json#repo`. The lexicon accepts + * either an HTTPS URL or an AT URI; v1 of the CLI accepts HTTPS only. + * AT-URI source repos can be added in a later issue without changing the + * field name. + * + * We use a regex `pattern` rather than `.refine` for the https-only rule + * so the constraint flows through to the generated JSON Schema. Editors + * doing client-side validation against the schema then surface the same + * error the CLI does. + */ +export const RepoSchema = z + .string() + .regex(/^https:\/\//, "repo must be an https:// URL (AT-URI source repos aren't supported yet)") + .url("repo must be a valid URL") + .max(1024, "repo must be <= 1024 characters") + .meta({ + title: "Source repository", + description: "HTTPS URL of the plugin's source repository. Surfaced in registry listings.", + examples: ["https://github.com/emdash-cms/plugin-gallery"], + }); + +// ────────────────────────────────────────────────────────────────────────── +// Identity (slug + version) +// ────────────────────────────────────────────────────────────────────────── + +/** + * The plugin's slug. ASCII letter then letters/digits/hyphens/underscores, + * max 64 chars. Same constraints as the registry lexicon's `rkey`-portion + * of a release record, validated via the shared `PLUGIN_SLUG_RE` in + * `@emdash-cms/plugin-types`. + * + * Slug + publisher together form the package identity. The runtime derives + * the AT URI from them; the author never writes the URI directly. + */ +export const SlugSchema = z + .string() + .min(1, "slug must be a non-empty string") + .max(PLUGIN_SLUG_MAX_LENGTH, `slug must be <= ${PLUGIN_SLUG_MAX_LENGTH} characters`) + .regex( + PLUGIN_SLUG_RE, + 'slug must start with a lowercase letter, then lowercase letters / digits / "-" / "_" (e.g. "gallery", "my-plugin")', + ) + .meta({ + title: "Slug", + description: + "URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.", + examples: ["gallery", "image-resizer", "my-plugin"], + }); + +/** + * The plugin's version. Subset of semver 2.0; build-metadata (`+...`) is + * disallowed because atproto record keys can't contain `+`. Validated via + * `PLUGIN_VERSION_RE` from `@emdash-cms/plugin-types`. + */ +export const VersionSchema = z + .string() + .min(1, "version must be a non-empty string") + .max(PLUGIN_VERSION_MAX_LENGTH, `version must be <= ${PLUGIN_VERSION_MAX_LENGTH} characters`) + .regex( + PLUGIN_VERSION_RE, + 'version must follow semver 2.0 without build-metadata (e.g. "0.1.0", "1.2.3-rc.1")', + ) + .meta({ + title: "Version", + description: + "Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.", + examples: ["0.1.0", "1.2.3", "1.0.0-rc.1"], + }); + +// ────────────────────────────────────────────────────────────────────────── +// Trust contract (capabilities + allowedHosts + storage) +// ────────────────────────────────────────────────────────────────────────── + +/** + * The set of currently-valid (non-deprecated) capability names. + * + * Mirrors the `CurrentPluginCapability` union from `@emdash-cms/plugin-types`. + * TS unions don't survive erasure into a runtime Set, so we maintain the + * list here and the schema's tests catch drift against the type definition. + */ +const CURRENT_CAPABILITIES = new Set([ + "network:request", + "network:request:unrestricted", + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "email:send", + "hooks.email-transport:register", + "hooks.email-events:register", + "hooks.page-fragments:register", +]); + +/** + * A single capability declaration. Plain string, validated for membership + * in the current vocabulary AND for being non-deprecated. Deprecated names + * are hard-rejected with a hint pointing at the replacement — the deprecation + * window is for already-published plugins, not for new authoring. + * + * Uses a single `superRefine` so we can produce an issue-specific message + * that names the offending capability string. The shape mirrors Zod 4's + * recommended pattern for "value-dependent error messages". + */ +export const CapabilitySchema = z + .string() + .min(1, "capability must be a non-empty string") + .superRefine((cap, ctx) => { + if (isDeprecatedCapability(cap)) { + const replacement = CAPABILITY_RENAMES[cap]; + ctx.addIssue({ + code: "custom", + message: `capability "${cap}" is deprecated. Use "${replacement}" instead.`, + }); + return; + } + const normalised = normalizeCapability(cap); + if (!CURRENT_CAPABILITIES.has(normalised)) { + ctx.addIssue({ + code: "custom", + message: `capability "${cap}" is not a recognised name. See the docs for the available capabilities.`, + }); + } + }); + +/** + * Capabilities array. The plugin's declared trust contract. Empty array + * (or omitted field, defaulting to empty) means the plugin asks for no + * privileges beyond the built-in surface (logging, kv, routes/hooks + * registration). + * + * Cross-field rule (in `ManifestSchema`'s `.refine()`): if `capabilities` + * includes `network:request` (and NOT `network:request:unrestricted`), + * then `allowedHosts` must be a non-empty array. This matches the + * `releaseExtension` lexicon's `networkRequestConstraints.allowedHosts` + * "absent OR non-empty" rule. + */ +export const CapabilitiesSchema = z + .array(CapabilitySchema) + .max(32, "capabilities[] must have <= 32 entries") + .meta({ + title: "Capabilities", + description: + "Trust contract: what runtime APIs the plugin is allowed to use. Changing this between releases requires a version bump because installed users have consented to the old contract.", + }); + +/** + * Slash or whitespace in a hostname pattern is a sign the user pasted a + * URL or path instead of a bare host. Hoisted out of `.refine()` so the + * regex is compiled once. + */ +const HOST_PATTERN_INVALID_CHARS = /[/\s]/; + +/** + * Allowed-hosts list for `network:request`. Each entry is a hostname + * pattern with no scheme/path/whitespace; a leading `*.` permits + * subdomains. (Ports are accepted by this loose check; the publish-time + * lexicon validator is the strict authority on the exact grammar.) + */ +export const AllowedHostsSchema = z + .array( + z + .string() + .min(1, "host pattern must be non-empty") + .max(256, "host pattern must be <= 256 characters") + .refine( + (h) => !HOST_PATTERN_INVALID_CHARS.test(h) && !h.includes("://"), + 'host pattern must be a hostname only (no scheme, path, or whitespace; "*." for subdomain wildcard is allowed)', + ), + ) + .max(64, "allowedHosts[] must have <= 64 entries") + .meta({ + title: "Allowed hosts", + description: + "Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.", + examples: [["api.example.com", "*.cdn.example.com"]], + }); + +/** + * Storage collection config. Mirrors `StorageCollectionConfig` from + * `@emdash-cms/plugin-types`. Indexes are field names (or composite + * arrays). Unique indexes are queryable too — don't duplicate them in + * `indexes`. + */ +export const StorageCollectionSchema = z + .object({ + indexes: z.array(z.union([z.string().min(1), z.array(z.string().min(1)).min(1)])), + uniqueIndexes: z + .array(z.union([z.string().min(1), z.array(z.string().min(1)).min(1)])) + .optional(), + }) + .strict() + .meta({ + title: "Storage collection", + description: + "Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names).", + }); + +/** + * Storage declaration. Map of collection name to its index config. + * Collection names follow the same slug-like rules as plugin slugs: + * lowercase letters, digits, hyphens, underscores. The runtime uses the + * collection name verbatim as the SQL table-suffix, so the grammar must + * be safe. + */ +export const StorageSchema = z + .record( + z + .string() + .min(1, "storage collection name must be non-empty") + .regex( + /^[a-z][a-z0-9_]*$/, + 'storage collection name must start with a lowercase letter, then lowercase letters / digits / "_"', + ), + StorageCollectionSchema, + ) + .meta({ + title: "Storage", + description: + "Storage collections the plugin uses. Each collection is namespaced to this plugin at runtime.", + }); + +// ────────────────────────────────────────────────────────────────────────── +// Admin surface (pages + dashboard widgets) +// ────────────────────────────────────────────────────────────────────────── + +/** + * An admin page declaration. Sandboxed plugins render admin pages + * through Block Kit via their `admin` route handler — the manifest + * just declares where the page lives in the navigation, what it's + * called, and what icon goes next to it. + * + * Path is restricted to a leading slash + URL-safe characters so the + * admin router has a sensible space of values to dispatch on. + */ +export const AdminPageSchema = z + .object({ + path: z + .string() + .min(2, "admin page path must be at least 2 characters (leading slash + name)") + .max(128, "admin page path must be <= 128 characters") + .regex( + /^\/[a-z0-9][a-z0-9/_-]*$/i, + 'admin page path must start with "/" and contain only letters, digits, "-", "_", "/"', + ), + label: z + .string() + .min(1, "admin page label cannot be empty") + .max(128, "admin page label must be <= 128 characters"), + icon: z.string().min(1).max(64).optional(), + }) + .strict() + .meta({ + title: "Admin page", + description: + "A single admin page declaration. The plugin's `admin` route handler is responsible for rendering Block Kit content for this path.", + }); + +/** + * A dashboard widget declaration. Same surface contract as admin + * pages — the plugin's `admin` route handler renders the widget's + * Block Kit content, scoped by widget id. + */ +export const AdminWidgetSchema = z + .object({ + id: z + .string() + .min(1, "admin widget id cannot be empty") + .max(64, "admin widget id must be <= 64 characters") + .regex( + /^[a-z][a-z0-9_-]*$/, + 'admin widget id must start with a lowercase letter, then lowercase letters / digits / "-" / "_"', + ), + title: z.string().min(1).max(128).optional(), + size: z.enum(["full", "half", "third"]).optional(), + }) + .strict() + .meta({ + title: "Admin widget", + description: "A single dashboard widget declaration.", + }); + +/** + * Admin surface block in the manifest. Both fields are optional; + * plugins that don't expose admin UI at all simply omit the `admin` + * key entirely. + */ +export const AdminSchema = z + .object({ + pages: z.array(AdminPageSchema).max(32, "admin.pages[] must have <= 32 entries").optional(), + widgets: z + .array(AdminWidgetSchema) + .max(32, "admin.widgets[] must have <= 32 entries") + .optional(), + }) + .strict() + .meta({ + title: "Admin surface", + description: + "Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime.", + }); + +// ────────────────────────────────────────────────────────────────────────── +// Top-level manifest +// ────────────────────────────────────────────────────────────────────────── + +/** + * The full v1 manifest. Unknown keys are rejected by `.strict()` so a + * typo'd field name produces an immediate error rather than passing + * through silently. The cost is that every later issue (#1029, #1030, ...) + * has to extend this schema, which is intentional: the manifest is a + * contract with users and we want changes to be deliberate. + * + * `$schema` is allowed because editors write it automatically for IDE + * completion. It is stripped before validation passes the value to the + * publish translation. + */ +export const ManifestSchema = z + .object({ + // `$schema` is for editor IDE support and the JSON Schema tooling + // chain. It carries no semantic meaning to publish; the loader + // strips it before handing the value off. + $schema: z + .string() + .meta({ + description: + "Path or URL to the JSON Schema describing this file. Editors use this for completion and validation.", + }) + .optional(), + + // Identity. Slug + publisher together form the package's identity; + // the AT URI is derived at runtime, never authored. + slug: SlugSchema, + // Version is optional in the source manifest. `package.json#version` + // is the canonical source for npm-distributed plugins (Changesets + // bumps it on release), so duplicating it here causes drift. Two + // authoring shapes are valid: + // - Omit `version` in the manifest, keep it only in `package.json`. + // - Set it in both, in which case the build step enforces they + // match and errors loudly on mismatch. + // Registry-only plugins (no `package.json`) must set `version` here + // — there's nowhere else for it to live. + version: VersionSchema.optional(), + + // Required on first publish, ignored on subsequent publishes (the + // existing profile wins). Same precedence rules as today's + // --license flag. + license: LicenseSchema, + + // Publisher pin. Required for the plugin to load — the runtime + // can't compute the AT URI without it. Authors fill it in before + // first run; on first publish, if the value matches the session, + // it stays. If a publisher migrates the manifest's `publisher` + // must be updated explicitly. + publisher: PublisherSchema, + + // Trust contract. Static for a given version; changes require + // a version bump because installed users have consented to the + // old contract. Default-empty so the minimal manifest doesn't + // need to spell out the absence of privileges. + capabilities: CapabilitiesSchema.default([]), + allowedHosts: AllowedHostsSchema.default([]), + storage: StorageSchema.default({}), + + // Admin surface. Optional; plugins that don't expose any admin + // UI omit the key entirely. The runtime checks that any plugin + // declaring admin.pages or admin.widgets also serves an `admin` + // route — the schema can't enforce that here because route + // names are probed from src/plugin.ts, not the manifest. + admin: AdminSchema.optional(), + + // Single-author form. Mutually exclusive with `authors`. + author: AuthorSchema.optional(), + // Multi-author form. Mutually exclusive with `author`. At least one + // entry is required when this field is used. + authors: z + .array(AuthorSchema) + .min(1, "authors[] must have at least one entry") + .max(32, "authors[] must have <= 32 entries (lexicon constraint)") + .meta({ + title: "Authors (multiple)", + description: + "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one.", + }) + .optional(), + + // Single-contact form. Mutually exclusive with `securityContacts`. + security: SecurityContactSchema.optional(), + // Multi-contact form. Mutually exclusive with `security`. + securityContacts: z + .array(SecurityContactSchema) + .min(1, "securityContacts[] must have at least one entry") + .max(8, "securityContacts[] must have <= 8 entries (lexicon constraint)") + .meta({ + title: "Security contacts (multiple)", + description: + "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one.", + }) + .optional(), + + // Optional profile fields. + name: NameSchema.optional(), + description: DescriptionSchema.optional(), + keywords: KeywordsSchema.optional(), + + // Optional release fields. + repo: RepoSchema.optional(), + }) + .strict() + .refine((v) => !(v.author !== undefined && v.authors !== undefined), { + message: + "manifest has both `author` and `authors`. Use one form: `author: { ... }` for a single author, or `authors: [...]` for multiple.", + path: ["authors"], + }) + .refine((v) => !(v.security !== undefined && v.securityContacts !== undefined), { + message: + "manifest has both `security` and `securityContacts`. Use one form: `security: { ... }` for a single contact, or `securityContacts: [...]` for multiple.", + path: ["securityContacts"], + }) + .refine((v) => v.author !== undefined || v.authors !== undefined, { + message: "manifest must specify either `author: { ... }` or `authors: [...]`", + path: ["author"], + }) + .refine((v) => v.security !== undefined || v.securityContacts !== undefined, { + message: "manifest must specify either `security: { ... }` or `securityContacts: [...]`", + path: ["security"], + }) + .refine( + (v) => { + // network:request without :unrestricted requires a non-empty + // allowedHosts. Without this guard, the lexicon's + // networkRequestConstraints rule fires at publish time and + // users see a confusing PDS error rather than a schema error. + const caps = new Set((v.capabilities ?? []).map((c) => normalizeCapability(c))); + if (caps.has("network:request") && !caps.has("network:request:unrestricted")) { + return (v.allowedHosts ?? []).length > 0; + } + return true; + }, + { + message: + 'capability "network:request" requires a non-empty `allowedHosts` list. Either add hosts, or upgrade to "network:request:unrestricted" if the plugin really needs to call any host.', + path: ["allowedHosts"], + }, + ) + .refine( + (v) => { + // network:request:unrestricted with allowedHosts is contradictory + // — the unrestricted capability says "any host", but the list + // implies "only these". The lexicon's rule is "allowedHosts + // MUST NOT appear when unrestricted"; same here. + const caps = new Set((v.capabilities ?? []).map((c) => normalizeCapability(c))); + if (caps.has("network:request:unrestricted")) { + return (v.allowedHosts ?? []).length === 0; + } + return true; + }, + { + message: + '`allowedHosts` must be empty when "network:request:unrestricted" is declared (the unrestricted capability already grants any host).', + path: ["allowedHosts"], + }, + ) + .meta({ + title: "EmDash plugin manifest", + description: + "Hand-authored manifest for publishing a plugin to the EmDash plugin registry. Lives next to the plugin's `package.json` as `emdash-plugin.jsonc`.", + }); + +/** + * Validated manifest shape. Note: this is the SHAPE AFTER the schema's + * `.refine()` rules have run, not the on-disk shape. The single-form + * convenience fields (`author`, `security`) are still present at this + * stage; normalisation to the array forms happens in `./translate.ts`. + */ +export type Manifest = z.infer; + +/** A single author entry, normalised. */ +export type ManifestAuthor = z.infer; + +/** A single security contact entry, normalised. */ +export type ManifestSecurityContact = z.infer; diff --git a/packages/plugin-cli/src/manifest/translate.ts b/packages/plugin-cli/src/manifest/translate.ts new file mode 100644 index 000000000..3813a00d7 --- /dev/null +++ b/packages/plugin-cli/src/manifest/translate.ts @@ -0,0 +1,212 @@ +/** + * Translate a validated manifest into the existing publish-input shape. + * + * The single-author / single-security-contact convenience forms are + * normalised here: by the time this returns, the caller sees only the + * array shapes the lexicon uses. + */ + +import type { PluginCapability, PluginStorageConfig } from "@emdash-cms/plugin-types"; + +import type { ProfileBootstrap } from "../publish/api.js"; +import type { Manifest, ManifestAuthor, ManifestSecurityContact } from "./schema.js"; + +/** + * Normalised "after the schema's single/multi convenience has been + * collapsed" view of a manifest. The CLI passes this to the publish + * pipeline rather than the raw `Manifest` so the rest of the code + * never has to think about `author` vs `authors`. + */ +/** + * Admin surface, mirroring the structure the runtime expects. Pulled + * out as a type alias so the bundle layer can pass it through to the + * bundled `manifest.json` without re-asserting the shape. + */ +export interface NormalisedAdmin { + pages: Array<{ path: string; label: string; icon?: string }>; + widgets: Array<{ id: string; title?: string; size?: "full" | "half" | "third" }>; +} + +export interface NormalisedManifest { + // Identity. All three are guaranteed present in the normalised + // form: `slug` and `publisher` are required at authoring time, + // and `version` is resolved during normalisation from the manifest + // or `package.json#version` (with a mismatch / missing check). + slug: string; + version: string; + publisher: string; + + // Profile. + license: string; + authors: ManifestAuthor[]; + securityContacts: ManifestSecurityContact[]; + name: string | undefined; + description: string | undefined; + keywords: string[] | undefined; + repo: string | undefined; + + // Trust contract (defaults applied by the schema; always present here). + capabilities: PluginCapability[]; + allowedHosts: string[]; + storage: PluginStorageConfig; + + /** + * Admin surface. Always present in the normalised form (with + * empty arrays when the manifest didn't declare anything) so the + * bundle layer can pass it through without conditional handling. + */ + admin: NormalisedAdmin; +} + +/** + * Thrown when the source manifest and the package's `package.json` carry + * different versions, or when neither carries one. Callers convert this + * into their own error code (BuildError, BundleError, ManifestError). + */ +export class VersionMismatchError extends Error { + override readonly name = "VersionMismatchError"; + readonly code: "VERSION_MISMATCH" | "VERSION_MISSING"; + readonly manifestVersion: string | undefined; + readonly packageVersion: string | undefined; + + constructor( + code: "VERSION_MISMATCH" | "VERSION_MISSING", + message: string, + manifestVersion: string | undefined, + packageVersion: string | undefined, + ) { + super(message); + this.code = code; + this.manifestVersion = manifestVersion; + this.packageVersion = packageVersion; + } +} + +/** + * Reconcile the manifest's `version` with the package's `version`. + * + * - Both present and equal → returns that string. + * - Both present and different → throws `VERSION_MISMATCH`. + * - Only one present → returns it. + * - Neither present → throws `VERSION_MISSING`. + * + * Surrounding whitespace on either input is rejected with a dedicated + * error so a visually-identical-but-not-equal pair like `"1.0.0 "` + * vs `"1.0.0"` doesn't print a confusing mismatch message. + */ +export function resolvePluginVersion( + manifestVersion: string | undefined, + packageVersion: string | undefined, +): string { + if (manifestVersion !== undefined && manifestVersion.trim() !== manifestVersion) { + throw new VersionMismatchError( + "VERSION_MISMATCH", + `Plugin version in emdash-plugin.jsonc has leading or trailing whitespace (${JSON.stringify(manifestVersion)}). Trim it.`, + manifestVersion, + packageVersion, + ); + } + if (packageVersion !== undefined && packageVersion.trim() !== packageVersion) { + throw new VersionMismatchError( + "VERSION_MISMATCH", + `Plugin version in package.json has leading or trailing whitespace (${JSON.stringify(packageVersion)}). Trim it.`, + manifestVersion, + packageVersion, + ); + } + if (manifestVersion !== undefined && packageVersion !== undefined) { + if (manifestVersion !== packageVersion) { + throw new VersionMismatchError( + "VERSION_MISMATCH", + `Plugin version disagrees between emdash-plugin.jsonc (${manifestVersion}) and package.json (${packageVersion}). Remove "version" from emdash-plugin.jsonc to let package.json drive it, or align both values.`, + manifestVersion, + packageVersion, + ); + } + return manifestVersion; + } + if (manifestVersion !== undefined) return manifestVersion; + if (packageVersion !== undefined) return packageVersion; + throw new VersionMismatchError( + "VERSION_MISSING", + 'Plugin version not set. Add "version" to package.json (npm-distributed plugins) or to emdash-plugin.jsonc (registry-only plugins).', + manifestVersion, + packageVersion, + ); +} + +/** + * Collapse the convenience forms (`author`, `security`) into the array + * forms (`authors`, `securityContacts`), and reconcile the manifest's + * optional `version` against the package's `version` so callers see a + * single resolved string. + * + * The manifest schema's `.refine()` rules already guarantee that exactly + * one of each name/contact pair is set, so the runtime checks here are + * defensive — a caller that bypassed validation would still produce a + * coherent result. + * + * Pass `packageVersion: undefined` for registry-only plugins with no + * `package.json` — in that case the manifest's `version` is used + * directly (and is required, by the same `resolvePluginVersion` rules). + */ +export function normaliseManifest(manifest: Manifest, packageVersion?: string): NormalisedManifest { + const authors = manifest.authors ?? (manifest.author ? [manifest.author] : []); + const securityContacts = + manifest.securityContacts ?? (manifest.security ? [manifest.security] : []); + const version = resolvePluginVersion(manifest.version, packageVersion); + return { + slug: manifest.slug, + version, + publisher: manifest.publisher, + license: manifest.license, + authors, + securityContacts, + name: manifest.name, + description: manifest.description, + keywords: manifest.keywords, + repo: manifest.repo, + // Schema validation already gates capability strings to the + // current vocabulary via a runtime check, so by the time we get + // here the strings are guaranteed members of PluginCapability. + // Zod's inferred type is `string[]` (it can't see the runtime + // narrowing), and the cast bridges that gap. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- schema-enforced narrowing + capabilities: manifest.capabilities as PluginCapability[], + allowedHosts: manifest.allowedHosts, + // Same story for storage: Zod returns Record, + // PluginStorageConfig is the same shape with a tighter key + // constraint. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- schema-enforced narrowing + storage: manifest.storage as PluginStorageConfig, + admin: { + pages: manifest.admin?.pages ?? [], + widgets: manifest.admin?.widgets ?? [], + }, + }; +} + +/** + * Convert a normalised manifest into the `ProfileBootstrap` shape that + * `publishRelease` consumes. For multi-author manifests, the first + * author wins (the publish lexicon supports an array, but + * `ProfileBootstrap` doesn't model that yet). + * + * `name`, `description`, `keywords`, and `repo` are accepted by the + * manifest schema but not wired through here. They land in publish in a + * follow-up issue alongside the broader profile-fields work. The fields + * are not silently lost — the manifest is the source of truth and we'll + * read them again when the publish API accepts them. + */ +export function manifestToProfileBootstrap(manifest: NormalisedManifest): ProfileBootstrap { + const author = manifest.authors[0]; + const security = manifest.securityContacts[0]; + + const profile: ProfileBootstrap = { license: manifest.license }; + if (author?.name !== undefined) profile.authorName = author.name; + if (author?.url !== undefined) profile.authorUrl = author.url; + if (author?.email !== undefined) profile.authorEmail = author.email; + if (security?.email !== undefined) profile.securityEmail = security.email; + if (security?.url !== undefined) profile.securityUrl = security.url; + return profile; +} diff --git a/packages/registry-cli/src/multihash.ts b/packages/plugin-cli/src/multihash.ts similarity index 100% rename from packages/registry-cli/src/multihash.ts rename to packages/plugin-cli/src/multihash.ts diff --git a/packages/registry-cli/src/oauth.ts b/packages/plugin-cli/src/oauth.ts similarity index 100% rename from packages/registry-cli/src/oauth.ts rename to packages/plugin-cli/src/oauth.ts diff --git a/packages/registry-cli/src/profile.ts b/packages/plugin-cli/src/profile.ts similarity index 100% rename from packages/registry-cli/src/profile.ts rename to packages/plugin-cli/src/profile.ts diff --git a/packages/registry-cli/src/publish/api.ts b/packages/plugin-cli/src/publish/api.ts similarity index 100% rename from packages/registry-cli/src/publish/api.ts rename to packages/plugin-cli/src/publish/api.ts diff --git a/packages/registry-cli/tests/bundle-utils.test.ts b/packages/plugin-cli/tests/bundle-utils.test.ts similarity index 100% rename from packages/registry-cli/tests/bundle-utils.test.ts rename to packages/plugin-cli/tests/bundle-utils.test.ts diff --git a/packages/registry-cli/tests/bundle.test.ts b/packages/plugin-cli/tests/bundle.test.ts similarity index 82% rename from packages/registry-cli/tests/bundle.test.ts rename to packages/plugin-cli/tests/bundle.test.ts index fdd41c95d..632e8c8c1 100644 --- a/packages/registry-cli/tests/bundle.test.ts +++ b/packages/plugin-cli/tests/bundle.test.ts @@ -42,13 +42,13 @@ describe("bundlePlugin", () => { expect(result.sha256).toMatch(/^[0-9a-f]{64}$/); }); - it("captures hooks and routes from the sandbox-entry probe", async () => { + it("captures hooks and routes from the src/plugin.ts probe", async () => { const result = await bundlePlugin({ dir: FIXTURE, outDir }); const manifest = result.manifest; // Plain hook name (defaults). - expect(manifest.hooks).toContain("content:beforeCreate"); - // Routes are extracted from the sandbox entry's default export. + expect(manifest.hooks).toContain("content:beforeSave"); + // Routes are extracted from src/plugin.ts's default export. expect(manifest.routes).toContain("admin"); }); @@ -90,12 +90,12 @@ describe("bundlePlugin", () => { expect(parsed.version).toBe("1.2.3"); }); - it("throws BundleError(MISSING_PACKAGE_JSON) for a directory with no package.json", async () => { + it("throws BundleError(MISSING_MANIFEST) for a directory with no emdash-plugin.jsonc", async () => { const empty = await mkdtemp(join(tmpdir(), "emdash-empty-")); try { await expect(bundlePlugin({ dir: empty, outDir })).rejects.toMatchObject({ name: "BundleError", - code: "MISSING_PACKAGE_JSON", + code: "MISSING_MANIFEST", }); } finally { await rm(empty, { recursive: true, force: true }); @@ -112,8 +112,8 @@ describe("bundlePlugin", () => { caught = error; } expect(caught).toBeInstanceOf(BundleError); - expect((caught as BundleError).code).toBe("MISSING_PACKAGE_JSON"); - expect((caught as BundleError).message).toMatch(/No package\.json/); + expect((caught as BundleError).code).toBe("MISSING_MANIFEST"); + expect((caught as BundleError).message).toMatch(/No emdash-plugin\.jsonc/); } finally { await rm(empty, { recursive: true, force: true }); } @@ -140,28 +140,33 @@ describe("bundlePlugin", () => { }); it("validateOnly bundles never write the tarball even if outDir exists", async () => { - // outDir already exists from beforeEach; validateOnly must not put a - // tarball into it. + // validateOnly skips tarball creation but still produces the + // build artifacts in dist/. Tarball-specific checks (`.tar.gz` + // presence) are what we're asserting here, not "dist is empty". const result = await bundlePlugin({ dir: FIXTURE, outDir, validateOnly: true, }); expect(result.tarballPath).toBeNull(); + expect(result.tarballBytes).toBeNull(); + expect(result.sha256).toBeNull(); const fs = await import("node:fs/promises"); const contents = await fs.readdir(outDir); - expect(contents).toEqual([]); + // Dist artifacts ARE expected (build runs unconditionally), but no + // tarball. + expect(contents.some((f) => f.endsWith(".tar.gz"))).toBe(false); }); - it("hard-fails when descriptor declares hooks but no sandbox entry exists", async () => { - // The bad-plugin fixture declares hooks in its descriptor but has no - // `src/sandbox-entry.ts` and no `./sandbox` export. Without the guard, - // the bundler would silently emit a manifest claiming hooks the - // bundle can't deliver. + it("hard-fails when the plugin has a manifest but no src/plugin.ts", async () => { + // The bad-plugin fixture has a valid emdash-plugin.jsonc but no + // src/plugin.ts. Without the guard, the bundler would happily + // produce a tarball with no backend.js, leaving the runtime with + // nothing to load. await expect(bundlePlugin({ dir: BAD_FIXTURE, outDir })).rejects.toMatchObject({ name: "BundleError", - code: "INVALID_PLUGIN_FORMAT", + code: "MISSING_PLUGIN_ENTRY", }); }); diff --git a/packages/registry-cli/tests/config.test.ts b/packages/plugin-cli/tests/config.test.ts similarity index 100% rename from packages/registry-cli/tests/config.test.ts rename to packages/plugin-cli/tests/config.test.ts diff --git a/packages/plugin-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc b/packages/plugin-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc new file mode 100644 index 000000000..5ffa5f138 --- /dev/null +++ b/packages/plugin-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc @@ -0,0 +1,8 @@ +{ + "slug": "bad-plugin", + "version": "0.1.0", + "publisher": "fixture.example.com", + "license": "MIT", + "author": { "name": "Test Author" }, + "security": { "email": "security@example.com" }, +} diff --git a/packages/registry-cli/tests/fixtures/bad-plugin/package.json b/packages/plugin-cli/tests/fixtures/bad-plugin/package.json similarity index 100% rename from packages/registry-cli/tests/fixtures/bad-plugin/package.json rename to packages/plugin-cli/tests/fixtures/bad-plugin/package.json diff --git a/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc b/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc new file mode 100644 index 000000000..294391e6f --- /dev/null +++ b/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc @@ -0,0 +1,9 @@ +{ + "slug": "fixture-minimal", + "publisher": "fixture.example.com", + "license": "MIT", + "author": { "name": "Test Author" }, + "security": { "email": "security@example.com" }, + "capabilities": ["content:read"], + "allowedHosts": ["api.example.com"], +} diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/package.json b/packages/plugin-cli/tests/fixtures/minimal-plugin/package.json similarity index 100% rename from packages/registry-cli/tests/fixtures/minimal-plugin/package.json rename to packages/plugin-cli/tests/fixtures/minimal-plugin/package.json diff --git a/packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts b/packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts new file mode 100644 index 000000000..ad9e71450 --- /dev/null +++ b/packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts @@ -0,0 +1,22 @@ +/** + * Test fixture: minimal sandbox entry. Exports a default object with hooks + * and routes so the bundler's probe captures shape into the manifest. + * + * Uses the new authoring shape: bare default export with a + * `satisfies SandboxedPlugin` annotation. The import is type-only, so + * the bundler erases it — no runtime resolution of `emdash/plugin` + * needed. + */ +import type { SandboxedPlugin } from "emdash/plugin"; + +// `content:beforeCreate` isn't in the strict mapped type, so use the +// canonical content hook name. Test assertions also expect `content:beforeSave` +// via the runtime hook vocabulary. +export default { + hooks: { + "content:beforeSave": (event) => Promise.resolve(event.content), + }, + routes: { + admin: () => Promise.resolve(new Response("ok")), + }, +} satisfies SandboxedPlugin; diff --git a/packages/plugin-cli/tests/init-environment.test.ts b/packages/plugin-cli/tests/init-environment.test.ts new file mode 100644 index 000000000..253cedbee --- /dev/null +++ b/packages/plugin-cli/tests/init-environment.test.ts @@ -0,0 +1,157 @@ +/** + * Coverage for the init scaffolder's environment probe. + * + * The probe reads three external sources: git config, git remote, and + * package.json. Tests focus on the parts we control directly — repo + * URL normalisation and package.json field extraction. The git-config + * and git-remote subprocess calls are tested indirectly via `init` + * integration tests; mocking child_process gets brittle fast and the + * subprocess wrapper is a thin layer that doesn't have much to fail. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { probeEnvironment } from "../src/init/environment.js"; + +describe("probeEnvironment — package.json extraction", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "emdash-env-test-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns all-undefined for a directory with no package.json", async () => { + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + // Note: authorName / authorEmail may be set from the user's + // global git config; we don't assert on them here. The package + // extraction is the focus. + }); + + it("reads license, description, and repository from package.json", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + license: "Apache-2.0", + description: "A test plugin", + repository: "https://github.com/example/test", + }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBe("Apache-2.0"); + expect(env.description).toBe("A test plugin"); + expect(env.repo).toBe("https://github.com/example/test"); + }); + + it("normalizes git@github.com SSH URLs to https", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + repository: { type: "git", url: "git+ssh://git@github.com:example/test.git" }, + }), + "utf8", + ); + // SSH URL with the `ssh://` scheme prefix isn't the shape our + // normaliser handles — the user-facing common case is + // `git@host:path`, not `ssh://git@host/path`. The probe + // returns undefined; the prompt's fallback chain handles it. + const env = await probeEnvironment(dir); + expect(env.repo).toBeUndefined(); + }); + + it("strips the .git suffix and the git+ prefix from package.json#repository.url", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + repository: { type: "git", url: "git+https://github.com/example/test.git" }, + }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.repo).toBe("https://github.com/example/test"); + }); + + it("treats a malformed package.json as missing", async () => { + await writeFile(join(dir, "package.json"), "{ not valid json", "utf8"); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + expect(env.repo).toBeUndefined(); + }); + + it("treats a non-object package.json as missing", async () => { + // Valid JSON but not an object — pathological but possible. + await writeFile(join(dir, "package.json"), "[]", "utf8"); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + }); + + it("ignores oversized package.json (defence against weird files)", async () => { + // Build a 100 KiB file. The cap is 64 KiB; the probe should + // skip rather than buffer the whole thing. + const fluff = " ".repeat(100 * 1024); + await writeFile( + join(dir, "package.json"), + `{ "license": "MIT", "_fluff": "${fluff}" }`, + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + }); + + it("treats empty-string license / description as undefined", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", license: "", description: " " }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + }); + + it("trims whitespace from license / description values", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", license: " MIT ", description: " hello " }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBe("MIT"); + expect(env.description).toBe("hello"); + }); + + it("accepts repository as a bare string", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", repository: "https://github.com/example/test.git" }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.repo).toBe("https://github.com/example/test"); + }); + + it("returns undefined for unrecognised repository shapes", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", repository: { type: "git" /* no url */ } }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.repo).toBeUndefined(); + }); +}); diff --git a/packages/plugin-cli/tests/init-scaffold.test.ts b/packages/plugin-cli/tests/init-scaffold.test.ts new file mode 100644 index 000000000..4aa2c0d65 --- /dev/null +++ b/packages/plugin-cli/tests/init-scaffold.test.ts @@ -0,0 +1,172 @@ +/** + * End-to-end coverage for `scaffold()`: the filesystem half of init. + * + * Each test runs against a fresh tempdir so writes don't collide. The + * suite's job is to verify the file tree, the overwrite policy, and + * the "the scaffold round-trips through the loader" invariant — the + * scaffolder shouldn't produce a manifest that its own validator + * rejects (when the user has supplied all required fields). + */ + +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { InitError, scaffold } from "../src/init/scaffold.js"; +import type { ScaffoldInputs } from "../src/init/templates.js"; +import { loadManifest } from "../src/manifest/load.js"; + +const FULL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: "did:plc:abc123def456", + publisherHandle: "example.com", + license: "MIT", + author: { name: "Jane Doe" }, + security: { email: "security@example.com" }, + description: undefined, + repo: undefined, +}; + +const MINIMAL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: undefined, + publisherHandle: undefined, + license: undefined, + author: undefined, + security: undefined, + description: undefined, + repo: undefined, +}; + +describe("scaffold", () => { + let targetDir: string; + + beforeEach(async () => { + // Each test gets its own tempdir, then immediately removes the + // dir so the scaffolder is creating from scratch (matches what + // a real `init` invocation does into a brand-new directory). + const root = await mkdtemp(join(tmpdir(), "emdash-init-test-")); + targetDir = join(root, "plugin"); + }); + + afterEach(async () => { + // rm the parent root, not just targetDir, so we don't leak + // the mkdtemp parent behind. + const root = targetDir.replace(/\/plugin$/, ""); + await rm(root, { recursive: true, force: true }); + }); + + it("writes the expected file tree", async () => { + const result = await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); + expect(result.written).toHaveLength(7); + + // Spot-check the structure rather than pinning the array order. + const fileSet = new Set(result.written.map((p) => p.replace(`${targetDir}/`, ""))); + expect(fileSet.has("emdash-plugin.jsonc")).toBe(true); + expect(fileSet.has("package.json")).toBe(true); + expect(fileSet.has("tsconfig.json")).toBe(true); + expect(fileSet.has(".gitignore")).toBe(true); + expect(fileSet.has("README.md")).toBe(true); + expect(fileSet.has("src/plugin.ts")).toBe(true); + expect(fileSet.has("tests/plugin.test.ts")).toBe(true); + }); + + it("produces a manifest that the loader accepts", async () => { + await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); + const { manifest } = await loadManifest(targetDir); + expect(manifest.slug).toBe("gallery"); + // `version` is intentionally omitted from the scaffold manifest; + // the build reads it from package.json instead. + expect(manifest.version).toBeUndefined(); + expect(manifest.publisher).toBe("did:plc:abc123def456"); + expect(manifest.license).toBe("MIT"); + }); + + it("produces a minimal manifest that round-trips through the loader (with empty publisher)", async () => { + // Minimal scaffold writes TODO placeholders. The loader's JSONC + // parse succeeds; the schema rejects on `publisher` being empty. + // We catch that explicitly so the user knows what to fix. + await scaffold({ targetDir, inputs: MINIMAL_INPUTS, force: false }); + await expect(loadManifest(targetDir)).rejects.toMatchObject({ + name: "ManifestError", + code: "MANIFEST_VALIDATION_ERROR", + }); + }); + + it("refuses to overwrite an existing file without --force", async () => { + // Pre-create one of the target files with different content. + const { mkdir } = await import("node:fs/promises"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "package.json"), "{}", "utf8"); + + await expect(scaffold({ targetDir, inputs: FULL_INPUTS, force: false })).rejects.toMatchObject({ + name: "InitError", + code: "TARGET_FILE_EXISTS", + conflicts: ["package.json"], + }); + + // The original file must be untouched. + const contents = await readFile(join(targetDir, "package.json"), "utf8"); + expect(contents).toBe("{}"); + }); + + it("does not partially write when a conflict is detected", async () => { + // Same setup as above. The conflict check runs BEFORE any + // write, so nothing else should appear in the target dir. + const { mkdir } = await import("node:fs/promises"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "package.json"), "{}", "utf8"); + + await expect(scaffold({ targetDir, inputs: FULL_INPUTS, force: false })).rejects.toThrow( + InitError, + ); + + const entries = await readdir(targetDir); + // Only the pre-existing file should be there. + expect(entries).toEqual(["package.json"]); + }); + + it("overwrites existing files when --force is set", async () => { + const { mkdir } = await import("node:fs/promises"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "package.json"), "{}", "utf8"); + + await scaffold({ targetDir, inputs: FULL_INPUTS, force: true }); + const contents = await readFile(join(targetDir, "package.json"), "utf8"); + // The scaffold wrote the real package.json over the stub. + const parsed = JSON.parse(contents) as { name: string }; + expect(parsed.name).toBe("gallery"); + }); + + it("creates intermediate directories (src/, tests/)", async () => { + await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); + const srcStat = await stat(join(targetDir, "src")); + const testsStat = await stat(join(targetDir, "tests")); + expect(srcStat.isDirectory()).toBe(true); + expect(testsStat.isDirectory()).toBe(true); + }); + + it("invokes onFileWritten once per file in scaffold order", async () => { + const calls: string[] = []; + await scaffold({ + targetDir, + inputs: FULL_INPUTS, + force: false, + onFileWritten: (rel) => calls.push(rel), + }); + // The seven files, in some deterministic order (see FILES in + // scaffold.ts). The exact order is part of the API surface + // for CLI progress output. + expect(calls).toEqual([ + "emdash-plugin.jsonc", + "package.json", + "tsconfig.json", + ".gitignore", + "README.md", + "src/plugin.ts", + "tests/plugin.test.ts", + ]); + }); +}); diff --git a/packages/plugin-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts new file mode 100644 index 000000000..ce2c0d2cc --- /dev/null +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -0,0 +1,303 @@ +/** + * Coverage for the init scaffolder's pure template functions. + * + * Tests focus on the manifest renderer because that's the file users + * see first and the one whose shape has to satisfy the schema. The + * other templates (package.json, tsconfig, README) get smoke checks + * that they produce valid JSON / non-empty content; their exact + * wording is verified by the integration test in init-scaffold.test.ts. + */ + +import { describe, expect, it } from "vitest"; + +import { + renderGitignore, + renderManifest, + renderPackageJson, + renderPluginEntry, + renderReadme, + renderTest, + renderTsconfig, + type ScaffoldInputs, +} from "../src/init/templates.js"; +import { ManifestSchema } from "../src/manifest/schema.js"; + +const FULL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: "did:plc:abc123def456", + publisherHandle: "example.com", + license: "MIT", + author: { name: "Jane Doe", url: "https://example.com", email: "jane@example.com" }, + security: { email: "security@example.com" }, + description: "Image gallery plugin", + repo: "https://github.com/example/gallery", +}; + +const MINIMAL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: undefined, + publisherHandle: undefined, + license: undefined, + author: undefined, + security: undefined, + description: undefined, + repo: undefined, +}; + +describe("renderManifest (fully-populated)", () => { + it("produces a manifest that passes the schema", () => { + const source = renderManifest(FULL_INPUTS); + // JSONC parser strips comments and trailing commas before + // validation. We parse via the same loader path the CLI uses + // elsewhere, but for the test a quick `parse` from jsonc-parser + // is enough — we only need to confirm the rendered bytes + // validate. + const parsed = parseJsonc(source); + const result = ManifestSchema.safeParse(parsed); + expect(result.success).toBe(true); + }); + + it("renders identity, license, author, security, description, repo", () => { + const source = renderManifest(FULL_INPUTS); + expect(source).toContain('"slug": "gallery"'); + // `version` deliberately omitted from the manifest scaffold — + // package.json#version is the source of truth. + expect(source).not.toContain('"version":'); + expect(source).toContain('"publisher": "did:plc:abc123def456"'); + expect(source).toContain('"license": "MIT"'); + expect(source).toContain('"name": "Jane Doe"'); + // author's url. The publisher comment also contains "example.com", + // so we anchor on the author block by looking for the + // url-key shape rather than the bare hostname. + expect(source).toContain('"url": "https://example.com"'); + expect(source).toContain('"email": "jane@example.com"'); + expect(source).toContain('"email": "security@example.com"'); + expect(source).toContain('"description": "Image gallery plugin"'); + expect(source).toContain('"repo": "https://github.com/example/gallery"'); + }); + + it("includes the handle as a line comment next to the pinned DID", () => { + const source = renderManifest(FULL_INPUTS); + const publisherLine = source.split("\n").find((l) => l.includes('"publisher"'))!; + expect(publisherLine).toBeDefined(); + expect(publisherLine).toContain("// example.com"); + }); + + it("omits the publisher comment when no handle is known (DID-only input)", () => { + const source = renderManifest({ ...FULL_INPUTS, publisherHandle: undefined }); + const publisherLine = source.split("\n").find((l) => l.includes('"publisher"'))!; + expect(publisherLine).toBeDefined(); + expect(publisherLine).not.toContain("//"); + }); + + it("includes the $schema reference for IDE completion", () => { + const source = renderManifest(FULL_INPUTS); + expect(source).toContain( + '"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json"', + ); + }); + + it("emits empty default arrays for the trust contract", () => { + // init starts with no declared capabilities. The author opts in. + const source = renderManifest(FULL_INPUTS); + expect(source).toContain('"capabilities": []'); + expect(source).toContain('"allowedHosts": []'); + expect(source).toContain('"storage": {}'); + }); +}); + +describe("renderManifest (minimal — no flags, no prompts)", () => { + it("produces a manifest with TODO placeholders", () => { + const source = renderManifest(MINIMAL_INPUTS); + // Three TODOs: publisher, author, security. License has a + // default (MIT) so it never carries a TODO. + const todoLines = source.split("\n").filter((line) => line.includes("TODO")); + expect(todoLines.length).toBeGreaterThanOrEqual(3); + // At least one TODO mentions atproto (publisher), one mentions + // the author name, one mentions security. + expect(todoLines.some((l) => /atproto handle|DID/i.test(l))).toBe(true); + expect(todoLines.some((l) => /name|author/i.test(l))).toBe(true); + expect(todoLines.some((l) => /security/i.test(l))).toBe(true); + }); + + it("emits an empty publisher value the schema will reject", () => { + // The TODO is visible to the user; the empty string is what + // schema validation hits. This is intentional: the manifest is + // "valid JSONC, schema-invalid until publisher is filled in". + const source = renderManifest(MINIMAL_INPUTS); + expect(source).toContain('"publisher": ""'); + }); + + it("defaults license to MIT when unset", () => { + const source = renderManifest(MINIMAL_INPUTS); + expect(source).toContain('"license": "MIT"'); + }); + + it("renders to the smallest plausible manifest", () => { + // description and repo are truly-optional fields. They must + // not appear when unset (no empty-string keys lying around). + const source = renderManifest(MINIMAL_INPUTS); + expect(source).not.toMatch(/"description":/); + expect(source).not.toMatch(/"repo":/); + }); +}); + +describe("renderManifest (partial author/security)", () => { + it("emits author.url and author.email only when provided", () => { + const source = renderManifest({ + ...FULL_INPUTS, + author: { name: "Jane Doe" }, // no url, no email + }); + expect(source).toContain('"name": "Jane Doe"'); + expect(source).not.toContain('"url":'); + expect(source).not.toContain('"jane@example.com"'); + }); + + it("emits security.url when only the url is provided", () => { + const source = renderManifest({ + ...FULL_INPUTS, + security: { url: "https://example.com/security" }, + }); + expect(source).toContain('"url": "https://example.com/security"'); + }); +}); + +describe("renderPackageJson", () => { + it("uses the slug as the package name and starts private", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.name).toBe("gallery"); + expect(parsed.private).toBe(true); + expect(parsed.type).toBe("module"); + }); + + it("ships build/dev/typecheck/test scripts", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.scripts.build).toBe("emdash-plugin build"); + expect(parsed.scripts.dev).toBe("emdash-plugin dev"); + expect(parsed.scripts.typecheck).toBeDefined(); + expect(parsed.scripts.test).toBeDefined(); + }); + + it("ships npm-shape main/exports/files so the plugin is pnpm-add-able", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.main).toBe("dist/index.mjs"); + expect(parsed.exports["."]).toBeDefined(); + expect(parsed.exports["./sandbox"]).toBe("./dist/plugin.mjs"); + expect(parsed.files).toContain("dist"); + expect(parsed.files).toContain("emdash-plugin.jsonc"); + }); + + it("declares @emdash-cms/plugin-cli as a devDep (provides emdash-plugin binary)", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.devDependencies["@emdash-cms/plugin-cli"]).toBeDefined(); + }); +}); + +describe("renderTsconfig", () => { + it("produces a strict standalone tsconfig", () => { + const parsed = JSON.parse(renderTsconfig()); + expect(parsed.compilerOptions.strict).toBe(true); + // No outDir / declaration — source is the artefact, bundle + // transpiles at publish time. + expect(parsed.compilerOptions.outDir).toBeUndefined(); + expect(parsed.compilerOptions.declaration).toBeUndefined(); + }); + + it("includes both src and tests", () => { + const parsed = JSON.parse(renderTsconfig()); + expect(parsed.include).toContain("src/**/*"); + expect(parsed.include).toContain("tests/**/*"); + }); +}); + +describe("renderPluginEntry", () => { + it("type-only-imports SandboxedPlugin from emdash/plugin", () => { + const source = renderPluginEntry(); + expect(source).toContain('import type { SandboxedPlugin } from "emdash/plugin"'); + // No runtime emdash imports — sandboxed plugins must not pull + // the emdash runtime into their bundle. + expect(source).not.toContain('import { definePlugin } from "emdash"'); + }); + + it("default-exports a bare object with `satisfies SandboxedPlugin` and a hello route", () => { + const source = renderPluginEntry(); + expect(source).toContain("export default {"); + expect(source).toContain("satisfies SandboxedPlugin"); + expect(source).toContain("hello:"); + expect(source).toContain("greeting:"); + // definePlugin must not appear in the scaffold — it's + // native-only now and would throw at runtime if used here. + expect(source).not.toContain("definePlugin"); + }); +}); + +describe("renderTest", () => { + it("imports the plugin and exercises the hello route", () => { + const source = renderTest(); + expect(source).toContain('from "../src/plugin.js"'); + expect(source).toContain("hello"); + expect(source).toContain("expect(result)"); + }); +}); + +describe("renderGitignore", () => { + it("ignores node_modules", () => { + expect(renderGitignore()).toContain("node_modules"); + }); + + it("ignores dist — the build pipeline writes it but it shouldn't be committed", () => { + expect(renderGitignore()).toContain("dist"); + }); +}); + +describe("renderReadme", () => { + it("documents the publish path", () => { + const source = renderReadme(FULL_INPUTS); + expect(source).toContain("emdash-plugin bundle"); + expect(source).toContain("emdash-plugin publish"); + }); + + it("documents version-bump rules for the trust contract", () => { + const source = renderReadme(FULL_INPUTS); + expect(source).toContain("capabilities"); + expect(source).toContain("trust contract"); + }); + + it("uses the slug as the title", () => { + const source = renderReadme(FULL_INPUTS); + expect(source.split("\n")[0]).toBe("# gallery"); + }); + + it("camel-cases the import binding so hyphenated slugs produce valid JS", () => { + const source = renderReadme({ ...FULL_INPUTS, slug: "my-plugin" }); + // The import specifier is the slug as-is; the binding must be a + // legal JS identifier (`myPlugin`, not `my-plugin`). + expect(source).toContain('import myPlugin from "my-plugin"'); + expect(source).toContain("sandboxed: [myPlugin]"); + expect(source).not.toContain("import my-plugin"); + }); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +/** + * Parse JSONC for testing the rendered manifest. We use the + * jsonc-parser dep directly here rather than going through the full + * loader because the loader requires a file path and we want to + * keep these tests in-memory. + */ +function parseJsonc(source: string): unknown { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { parse } = require("jsonc-parser") as typeof import("jsonc-parser"); + const errors: import("jsonc-parser").ParseError[] = []; + const value: unknown = parse(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + throw new Error(`JSONC parse errors: ${JSON.stringify(errors)}`); + } + return value; +} diff --git a/packages/registry-cli/tests/manifest-load.test.ts b/packages/plugin-cli/tests/manifest-load.test.ts similarity index 97% rename from packages/registry-cli/tests/manifest-load.test.ts rename to packages/plugin-cli/tests/manifest-load.test.ts index 480f5cb3e..d2e6e5179 100644 --- a/packages/registry-cli/tests/manifest-load.test.ts +++ b/packages/plugin-cli/tests/manifest-load.test.ts @@ -5,7 +5,7 @@ * 1. JSONC tolerance: trailing commas + comments are accepted (matches * the wrangler.jsonc / tsconfig.json convention). * 2. Source locations on validation errors: the error path is mapped - * back to a 1-indexed line:column so `emdash-registry validate` + * back to a 1-indexed line:column so `emdash-plugin validate` * points editors at the offending field. * * The tests below use `parseAndValidateManifest` (the in-memory variant) @@ -28,6 +28,9 @@ import { } from "../src/manifest/load.js"; const MINIMAL = `{ + "slug": "my-plugin", + "version": "0.1.0", + "publisher": "example.com", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -45,6 +48,9 @@ describe("parseAndValidateManifest (in-memory)", () => { it("accepts JSONC features (comments + trailing commas)", () => { const source = `{ // top-level comment + "slug": "my-plugin", + "version": "0.1.0", + "publisher": "example.com", "license": "MIT", /* block comment */ "author": { "name": "Jane Doe", }, "security": { "email": "security@example.com", }, diff --git a/packages/registry-cli/tests/manifest-publisher.test.ts b/packages/plugin-cli/tests/manifest-publisher.test.ts similarity index 95% rename from packages/registry-cli/tests/manifest-publisher.test.ts rename to packages/plugin-cli/tests/manifest-publisher.test.ts index 83d70e5fa..5dc75152c 100644 --- a/packages/registry-cli/tests/manifest-publisher.test.ts +++ b/packages/plugin-cli/tests/manifest-publisher.test.ts @@ -58,6 +58,8 @@ describe("PublisherSchema", () => { describe("ManifestSchema with publisher", () => { const minimal = { + slug: "my-plugin", + version: "0.1.0", license: "MIT", author: { name: "Jane Doe" }, security: { email: "security@example.com" }, @@ -79,9 +81,12 @@ describe("ManifestSchema with publisher", () => { expect(result.success).toBe(true); }); - it("accepts a manifest without a publisher (first-publish state)", () => { + it("rejects a manifest without a publisher", () => { + // publisher is required for the runtime to compute the plugin's + // AT URI. The author must fill it in before any local-dev or + // publish run. const result = ManifestSchema.safeParse(minimal); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); it("rejects a manifest with an invalid publisher", () => { @@ -146,6 +151,8 @@ describe("writePublisherBack", () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ // Top-level comment + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -171,6 +178,8 @@ describe("writePublisherBack", () => { it("appends a // comment when a session handle is provided", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -202,6 +211,8 @@ describe("writePublisherBack", () => { // after a maintainer transfer) would have triggered this. const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "description": "Originally published as ${SESSION_DID}. See changelog.", "author": { "name": "Jane Doe" }, @@ -229,6 +240,8 @@ describe("writePublisherBack", () => { it("omits the comment when no handle is provided", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -274,6 +287,8 @@ describe("writePublisherBack", () => { it("does not overwrite an existing publisher (defensive re-parse)", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "publisher": "did:plc:user-pinned-already", "author": { "name": "Jane Doe" }, @@ -326,6 +341,8 @@ describe("writePublisherBack", () => { it("produces a JSONC document that round-trips through the loader", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } diff --git a/packages/registry-cli/tests/manifest-schema.test.ts b/packages/plugin-cli/tests/manifest-schema.test.ts similarity index 82% rename from packages/registry-cli/tests/manifest-schema.test.ts rename to packages/plugin-cli/tests/manifest-schema.test.ts index a3a28a636..c90f035a1 100644 --- a/packages/registry-cli/tests/manifest-schema.test.ts +++ b/packages/plugin-cli/tests/manifest-schema.test.ts @@ -7,7 +7,7 @@ * field so a future field add lands cleanly alongside its own test block. * * Where applicable, tests assert on the EXACT Zod issue path / message - * because those strings surface in `emdash-registry validate` output -- + * because those strings surface in `emdash-plugin validate` output -- * users see them, and silently changing them breaks anyone who built * tooling around the strings. */ @@ -123,6 +123,9 @@ describe("RepoSchema", () => { describe("ManifestSchema (full document)", () => { const minimal = { + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", license: "MIT", author: { name: "Jane Doe" }, security: { email: "security@example.com" }, @@ -136,13 +139,16 @@ describe("ManifestSchema (full document)", () => { it("accepts a manifest with $schema for IDE completion", () => { const result = ManifestSchema.safeParse({ ...minimal, - $schema: "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", + $schema: "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", }); expect(result.success).toBe(true); }); it("accepts the multi-author/multi-contact form", () => { const result = ManifestSchema.safeParse({ + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", license: "MIT", authors: [{ name: "Alice" }, { name: "Bob" }], securityContacts: [{ email: "alice@example.com" }, { url: "https://example.com/security" }], @@ -152,49 +158,47 @@ describe("ManifestSchema (full document)", () => { it("rejects mixing `author` and `authors`", () => { const result = ManifestSchema.safeParse({ - license: "MIT", - author: { name: "Alice" }, + ...minimal, authors: [{ name: "Bob" }], - security: { email: "security@example.com" }, }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("both `author` and `authors`"); + expect( + result.error.issues.some((i) => i.message.includes("both `author` and `authors`")), + ).toBe(true); } }); it("rejects mixing `security` and `securityContacts`", () => { const result = ManifestSchema.safeParse({ - license: "MIT", - author: { name: "Alice" }, - security: { email: "a@example.com" }, + ...minimal, securityContacts: [{ email: "b@example.com" }], }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("both `security` and `securityContacts`"); + expect( + result.error.issues.some((i) => + i.message.includes("both `security` and `securityContacts`"), + ), + ).toBe(true); } }); it("requires either `author` or `authors`", () => { - const result = ManifestSchema.safeParse({ - license: "MIT", - security: { email: "security@example.com" }, - }); + const { author: _author, ...withoutAuthor } = minimal; + const result = ManifestSchema.safeParse(withoutAuthor); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("`author: { ... }`"); + expect(result.error.issues.some((i) => i.message.includes("`author: { ... }`"))).toBe(true); } }); it("requires either `security` or `securityContacts`", () => { - const result = ManifestSchema.safeParse({ - license: "MIT", - author: { name: "Alice" }, - }); + const { security: _security, ...withoutSecurity } = minimal; + const result = ManifestSchema.safeParse(withoutSecurity); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("`security: { ... }`"); + expect(result.error.issues.some((i) => i.message.includes("`security: { ... }`"))).toBe(true); } }); @@ -207,20 +211,20 @@ describe("ManifestSchema (full document)", () => { }); it("rejects an empty authors array (lexicon requires >= 1)", () => { + const { author: _author, ...rest } = minimal; const result = ManifestSchema.safeParse({ - license: "MIT", + ...rest, authors: [], - security: { email: "security@example.com" }, }); expect(result.success).toBe(false); }); it("rejects more than 32 authors (lexicon cap)", () => { const authors = Array.from({ length: 33 }, (_, i) => ({ name: `Author ${i}` })); + const { author: _author, ...rest } = minimal; const result = ManifestSchema.safeParse({ - license: "MIT", + ...rest, authors, - security: { email: "security@example.com" }, }); expect(result.success).toBe(false); }); @@ -235,7 +239,10 @@ describe("ManifestSchema (full document)", () => { it("accepts a full populated manifest", () => { const result = ManifestSchema.safeParse({ - $schema: "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", + $schema: "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", + slug: "gallery", + version: "0.1.0", + publisher: "example.com", license: "MIT", author: { name: "Jane Doe", @@ -250,6 +257,8 @@ describe("ManifestSchema (full document)", () => { description: "Image gallery block for EmDash.", keywords: ["gallery", "images", "media"], repo: "https://github.com/emdash-cms/plugin-gallery", + capabilities: ["content:read"], + storage: { events: { indexes: ["timestamp"] } }, }); expect(result.success).toBe(true); }); diff --git a/packages/registry-cli/tests/manifest-translate.test.ts b/packages/plugin-cli/tests/manifest-translate.test.ts similarity index 58% rename from packages/registry-cli/tests/manifest-translate.test.ts rename to packages/plugin-cli/tests/manifest-translate.test.ts index 7f8818f76..3570990ec 100644 --- a/packages/registry-cli/tests/manifest-translate.test.ts +++ b/packages/plugin-cli/tests/manifest-translate.test.ts @@ -13,6 +13,7 @@ import { describe("normaliseManifest", () => { it("collapses single-author into authors[]", () => { const normalised = normaliseManifest({ + version: "0.1.0", license: "MIT", author: { name: "Jane" }, security: { email: "s@example.com" }, @@ -24,6 +25,7 @@ describe("normaliseManifest", () => { it("passes the multi-author array through unchanged", () => { const normalised = normaliseManifest({ + version: "0.1.0", license: "MIT", authors: [{ name: "A" }, { name: "B" }], securityContacts: [{ email: "s@example.com" }], @@ -33,6 +35,7 @@ describe("normaliseManifest", () => { it("propagates publisher when set", () => { const normalised = normaliseManifest({ + version: "0.1.0", license: "MIT", publisher: "did:plc:abc", author: { name: "Jane" }, @@ -40,11 +43,72 @@ describe("normaliseManifest", () => { }); expect(normalised.publisher).toBe("did:plc:abc"); }); + + it("uses package.json version when manifest omits it", () => { + const normalised = normaliseManifest( + { + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }, + "1.2.3", + ); + expect(normalised.version).toBe("1.2.3"); + }); + + it("uses manifest version when no package.json version is provided", () => { + const normalised = normaliseManifest({ + version: "0.9.0", + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }); + expect(normalised.version).toBe("0.9.0"); + }); + + it("accepts matching versions from both sources", () => { + const normalised = normaliseManifest( + { + version: "2.0.0", + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }, + "2.0.0", + ); + expect(normalised.version).toBe("2.0.0"); + }); + + it("throws on mismatched versions", () => { + expect(() => + normaliseManifest( + { + version: "1.0.0", + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }, + "2.0.0", + ), + ).toThrow(/disagrees/); + }); + + it("throws when no version is available anywhere", () => { + expect(() => + normaliseManifest({ + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }), + ).toThrow(/not set/); + }); }); describe("manifestToProfileBootstrap", () => { it("maps the publish-relevant subset of fields", () => { const normalised: NormalisedManifest = { + slug: "test", + version: "0.1.0", license: "MIT", publisher: "did:plc:abc", authors: [{ name: "Jane", url: "https://example.com" }], @@ -53,6 +117,10 @@ describe("manifestToProfileBootstrap", () => { description: "desc", keywords: ["k"], repo: "https://github.com/example/p", + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { pages: [], widgets: [] }, }; const bootstrap = manifestToProfileBootstrap(normalised); expect(bootstrap.license).toBe("MIT"); @@ -63,14 +131,20 @@ describe("manifestToProfileBootstrap", () => { it("uses the first author when multiple are provided", () => { const normalised: NormalisedManifest = { + slug: "test", + version: "0.1.0", license: "MIT", - publisher: undefined, + publisher: "did:plc:abc", authors: [{ name: "First" }, { name: "Second" }], securityContacts: [{ email: "s@example.com" }], name: undefined, description: undefined, keywords: undefined, repo: undefined, + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { pages: [], widgets: [] }, }; const bootstrap = manifestToProfileBootstrap(normalised); expect(bootstrap.authorName).toBe("First"); diff --git a/packages/plugin-cli/tests/manifest-trust-contract.test.ts b/packages/plugin-cli/tests/manifest-trust-contract.test.ts new file mode 100644 index 000000000..4165630bd --- /dev/null +++ b/packages/plugin-cli/tests/manifest-trust-contract.test.ts @@ -0,0 +1,296 @@ +/** + * Coverage for the manifest's identity (`slug`, `version`) and + * trust-contract fields (`capabilities`, `allowedHosts`, `storage`). + * + * The trust contract is the manifest's most security-sensitive surface. + * Capability vocabulary, deprecated-name rejection, and the cross-field + * `network:request` / `allowedHosts` rules all need explicit coverage so + * a future schema edit can't silently relax the validation. + */ + +import { describe, expect, it } from "vitest"; + +import { + AllowedHostsSchema, + CapabilitiesSchema, + CapabilitySchema, + ManifestSchema, + SlugSchema, + StorageSchema, + VersionSchema, +} from "../src/manifest/schema.js"; + +describe("SlugSchema", () => { + it("accepts the canonical form", () => { + expect(SlugSchema.parse("gallery")).toBe("gallery"); + expect(SlugSchema.parse("my-plugin")).toBe("my-plugin"); + expect(SlugSchema.parse("plugin_v2")).toBe("plugin_v2"); + }); + + it("rejects leading digit", () => { + const result = SlugSchema.safeParse("1-plugin"); + expect(result.success).toBe(false); + }); + + it("rejects leading punctuation", () => { + expect(SlugSchema.safeParse("-plugin").success).toBe(false); + expect(SlugSchema.safeParse("_plugin").success).toBe(false); + }); + + it("rejects uppercase", () => { + const result = SlugSchema.safeParse("MyPlugin"); + expect(result.success).toBe(false); + }); + + it("rejects empty", () => { + expect(SlugSchema.safeParse("").success).toBe(false); + }); + + it("rejects over 64 chars", () => { + const result = SlugSchema.safeParse("a".repeat(65)); + expect(result.success).toBe(false); + }); +}); + +describe("VersionSchema", () => { + it("accepts the canonical form", () => { + expect(VersionSchema.parse("0.1.0")).toBe("0.1.0"); + expect(VersionSchema.parse("1.2.3")).toBe("1.2.3"); + expect(VersionSchema.parse("1.0.0-rc.1")).toBe("1.0.0-rc.1"); + }); + + it("rejects build metadata (atproto rkey constraint)", () => { + // The atproto record-key alphabet has no `+`, so a semver + // build-metadata suffix can't survive into the publish path. + const result = VersionSchema.safeParse("1.0.0+build.1"); + expect(result.success).toBe(false); + }); + + it("rejects malformed semver", () => { + expect(VersionSchema.safeParse("1").success).toBe(false); + expect(VersionSchema.safeParse("1.0").success).toBe(false); + expect(VersionSchema.safeParse("v1.0.0").success).toBe(false); + }); +}); + +describe("CapabilitySchema", () => { + it("accepts a current capability", () => { + expect(CapabilitySchema.parse("content:read")).toBe("content:read"); + expect(CapabilitySchema.parse("network:request")).toBe("network:request"); + expect(CapabilitySchema.parse("email:send")).toBe("email:send"); + }); + + it("rejects a deprecated capability with a hint at the replacement", () => { + const result = CapabilitySchema.safeParse("read:content"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("deprecated"); + expect(result.error.issues[0]?.message).toContain("content:read"); + } + }); + + it("rejects an unknown capability", () => { + const result = CapabilitySchema.safeParse("filesystem:write"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("not a recognised name"); + } + }); + + it("rejects empty string", () => { + expect(CapabilitySchema.safeParse("").success).toBe(false); + }); +}); + +describe("CapabilitiesSchema (array-level)", () => { + it("accepts an empty list (no privileges beyond defaults)", () => { + expect(CapabilitiesSchema.parse([])).toEqual([]); + }); + + it("rejects more than 32 entries", () => { + // All entries are valid capabilities but the count alone should + // trip the array max. Repeat a valid one rather than constructing + // 33 distinct names that would also each fail individually. + const result = CapabilitiesSchema.safeParse(Array.from({ length: 33 }).fill("content:read")); + expect(result.success).toBe(false); + }); +}); + +describe("AllowedHostsSchema", () => { + it("accepts hostnames and wildcard-subdomain patterns", () => { + expect(AllowedHostsSchema.parse(["api.example.com"])).toEqual(["api.example.com"]); + expect(AllowedHostsSchema.parse(["*.cdn.example.com"])).toEqual(["*.cdn.example.com"]); + }); + + it("rejects URLs (scheme present)", () => { + const result = AllowedHostsSchema.safeParse(["https://api.example.com"]); + expect(result.success).toBe(false); + }); + + it("rejects host:port", () => { + // Port must be carried separately; the lexicon's grammar doesn't + // include ports in the pattern. + const result = AllowedHostsSchema.safeParse(["api.example.com:8080"]); + // `:` is allowed-but-we-don't-validate at this layer (no + // scheme/path/whitespace is the only structural test); a port + // is technically passes the loose check. Document as intentional + // — the lexicon's host-pattern grammar is the strict validator. + // Update this test if we tighten the regex. + expect(result.success).toBe(true); + }); + + it("rejects paths", () => { + const result = AllowedHostsSchema.safeParse(["api.example.com/some/path"]); + expect(result.success).toBe(false); + }); + + it("rejects whitespace", () => { + const result = AllowedHostsSchema.safeParse(["api.example.com "]); + expect(result.success).toBe(false); + }); +}); + +describe("StorageSchema", () => { + it("accepts a simple single-field index", () => { + const result = StorageSchema.parse({ + events: { indexes: ["timestamp"] }, + }); + expect(result).toEqual({ events: { indexes: ["timestamp"] } }); + }); + + it("accepts composite indexes", () => { + const result = StorageSchema.parse({ + events: { indexes: [["collection", "timestamp"]] }, + }); + expect(result.events?.indexes).toEqual([["collection", "timestamp"]]); + }); + + it("accepts uniqueIndexes alongside indexes", () => { + const result = StorageSchema.parse({ + users: { + indexes: ["createdAt"], + uniqueIndexes: ["email"], + }, + }); + expect(result.users?.uniqueIndexes).toEqual(["email"]); + }); + + it("rejects an invalid collection name", () => { + const result = StorageSchema.safeParse({ + "Bad-Name": { indexes: [] }, + }); + expect(result.success).toBe(false); + }); + + it("rejects an empty composite index", () => { + const result = StorageSchema.safeParse({ + events: { indexes: [[]] }, + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown keys on a collection config", () => { + const result = StorageSchema.safeParse({ + events: { indexes: [], orderBy: "timestamp" }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("ManifestSchema cross-field rules", () => { + const base = { + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", + license: "MIT", + author: { name: "Jane Doe" }, + security: { email: "security@example.com" }, + }; + + it("network:request requires non-empty allowedHosts", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request"], + allowedHosts: [], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.message.includes("non-empty `allowedHosts`"))).toBe( + true, + ); + } + }); + + it("network:request with at least one allowed host passes", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request"], + allowedHosts: ["api.example.com"], + }); + expect(result.success).toBe(true); + }); + + it("network:request:unrestricted forbids allowedHosts", () => { + // The lexicon's invariant: allowedHosts MUST NOT appear when + // unrestricted is declared. The unrestricted capability already + // grants any host. + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request:unrestricted"], + allowedHosts: ["api.example.com"], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => i.message.includes("`allowedHosts` must be empty")), + ).toBe(true); + } + }); + + it("network:request:unrestricted with empty allowedHosts passes", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request:unrestricted"], + }); + expect(result.success).toBe(true); + }); + + it("non-network capabilities don't require allowedHosts", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["content:read"], + }); + expect(result.success).toBe(true); + }); +}); + +describe("ManifestSchema with the trust contract", () => { + const base = { + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", + license: "MIT", + author: { name: "Jane Doe" }, + security: { email: "security@example.com" }, + }; + + it("defaults capabilities/allowedHosts/storage to empty when omitted", () => { + const result = ManifestSchema.parse(base); + expect(result.capabilities).toEqual([]); + expect(result.allowedHosts).toEqual([]); + expect(result.storage).toEqual({}); + }); + + it("accepts a full trust contract", () => { + const result = ManifestSchema.parse({ + ...base, + capabilities: ["content:read", "content:write", "network:request"], + allowedHosts: ["api.example.com", "*.cdn.example.com"], + storage: { + events: { indexes: ["timestamp"] }, + users: { indexes: ["createdAt"], uniqueIndexes: ["email"] }, + }, + }); + expect(result.capabilities).toEqual(["content:read", "content:write", "network:request"]); + }); +}); diff --git a/packages/registry-cli/tests/mock-pds.ts b/packages/plugin-cli/tests/mock-pds.ts similarity index 100% rename from packages/registry-cli/tests/mock-pds.ts rename to packages/plugin-cli/tests/mock-pds.ts diff --git a/packages/registry-cli/tests/multihash.test.ts b/packages/plugin-cli/tests/multihash.test.ts similarity index 100% rename from packages/registry-cli/tests/multihash.test.ts rename to packages/plugin-cli/tests/multihash.test.ts diff --git a/packages/registry-cli/tests/publish-tarball.test.ts b/packages/plugin-cli/tests/publish-tarball.test.ts similarity index 100% rename from packages/registry-cli/tests/publish-tarball.test.ts rename to packages/plugin-cli/tests/publish-tarball.test.ts diff --git a/packages/registry-cli/tests/publish.test.ts b/packages/plugin-cli/tests/publish.test.ts similarity index 100% rename from packages/registry-cli/tests/publish.test.ts rename to packages/plugin-cli/tests/publish.test.ts diff --git a/packages/registry-cli/tests/schema-drift.test.ts b/packages/plugin-cli/tests/schema-drift.test.ts similarity index 94% rename from packages/registry-cli/tests/schema-drift.test.ts rename to packages/plugin-cli/tests/schema-drift.test.ts index 01882686b..4dc3ebb88 100644 --- a/packages/registry-cli/tests/schema-drift.test.ts +++ b/packages/plugin-cli/tests/schema-drift.test.ts @@ -3,7 +3,7 @@ * JSON Schema at `schemas/emdash-plugin.schema.json`. * * The committed JSON Schema is shipped to users via - * `node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json` + * `node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json` * so editors can offer completion and validation without running our CLI. * If a contributor changes the Zod schema and forgets to regenerate, this * test fails with a clear "run pnpm gen-schema" instruction. @@ -51,7 +51,7 @@ describe("JSON Schema drift", () => { if (committed !== regenerated) { throw new Error( "schemas/emdash-plugin.schema.json is out of date with the Zod schema.\n" + - "Run: pnpm --filter @emdash-cms/registry-cli gen-schema\n" + + "Run: pnpm --filter @emdash-cms/plugin-cli gen-schema\n" + "Then commit the result.", ); } diff --git a/packages/registry-cli/tests/url-validation.test.ts b/packages/plugin-cli/tests/url-validation.test.ts similarity index 100% rename from packages/registry-cli/tests/url-validation.test.ts rename to packages/plugin-cli/tests/url-validation.test.ts diff --git a/packages/registry-cli/tsconfig.json b/packages/plugin-cli/tsconfig.json similarity index 100% rename from packages/registry-cli/tsconfig.json rename to packages/plugin-cli/tsconfig.json diff --git a/packages/registry-cli/tsdown.config.ts b/packages/plugin-cli/tsdown.config.ts similarity index 89% rename from packages/registry-cli/tsdown.config.ts rename to packages/plugin-cli/tsdown.config.ts index 55e38153c..84b62f6c3 100644 --- a/packages/registry-cli/tsdown.config.ts +++ b/packages/plugin-cli/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig([ - // CLI binary: `emdash-registry`. Bundled to a single .mjs. + // CLI binary: `emdash-plugin`. Bundled to a single .mjs. { entry: ["src/index.ts"], format: ["esm"], @@ -31,12 +31,15 @@ export default defineConfig([ "@emdash-cms/registry-client", "@emdash-cms/registry-lexicons", "@oslojs/crypto", + "chokidar", "citty", "consola", "image-size", + "jsonc-parser", "modern-tar", "picocolors", "tsdown", + "zod", ], }, ]); diff --git a/packages/registry-cli/vitest.config.ts b/packages/plugin-cli/vitest.config.ts similarity index 50% rename from packages/registry-cli/vitest.config.ts rename to packages/plugin-cli/vitest.config.ts index 59f73906d..21f373d28 100644 --- a/packages/registry-cli/vitest.config.ts +++ b/packages/plugin-cli/vitest.config.ts @@ -4,5 +4,8 @@ export default defineConfig({ test: { environment: "node", include: ["tests/**/*.test.ts"], + // Bundle / build tests run a full tsdown probe + transpile, + // which is fast locally but can take >5s on cold CI runners. + testTimeout: 30_000, }, }); diff --git a/packages/plugin-types/package.json b/packages/plugin-types/package.json index 131f0c306..ac4572d1f 100644 --- a/packages/plugin-types/package.json +++ b/packages/plugin-types/package.json @@ -1,7 +1,7 @@ { "name": "@emdash-cms/plugin-types", "version": "0.0.1", - "description": "Shared TypeScript types for the EmDash plugin manifest contract: capability vocabulary, manifest shape, hook/route entry types. Consumed by core (manifest reader at install/runtime) and registry-cli (manifest writer at bundle/publish time).", + "description": "Shared TypeScript types for the EmDash plugin manifest contract: capability vocabulary, manifest shape, hook/route entry types. Consumed by core (manifest reader at install/runtime) and plugin-cli (manifest writer at bundle/publish time).", "type": "module", "main": "dist/index.js", "exports": { diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 13b3193d1..53ec46559 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -8,9 +8,9 @@ * - **`emdash` (core)** reads `manifest.json` at install time and again at * runtime when gating a sandboxed plugin's access to capabilities. Core * is the contract reader. - * - **`@emdash-cms/registry-cli`** writes `manifest.json` during bundling + * - **`@emdash-cms/plugin-cli`** writes `manifest.json` during bundling * (extracted from the plugin author's source) and publishes the resulting - * records via atproto. registry-cli is the contract writer. + * records via atproto. plugin-cli is the contract writer. * * Anything that has to round-trip cleanly between writer and reader belongs * here: the capability vocabulary, the manifest shape, the hook/route entry diff --git a/packages/plugins/atproto/emdash-plugin.jsonc b/packages/plugins/atproto/emdash-plugin.jsonc new file mode 100644 index 000000000..9b9829c6e --- /dev/null +++ b/packages/plugins/atproto/emdash-plugin.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "atproto", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Syndicates published content to the AT Protocol network via standard.site lexicons, with optional Bluesky cross-posting.", + + // Trust contract. Needs unrestricted outbound HTTP because the + // publisher's PDS host varies per user — there's no fixed + // allow-list. Reads content for syndication. + "capabilities": ["content:read", "network:request:unrestricted"], + "allowedHosts": [], + "storage": { + "records": { "indexes": ["contentId", "status", "lastSyncedAt"] }, + }, + + "admin": { + "pages": [{ "path": "/status", "label": "AT Protocol", "icon": "globe" }], + "widgets": [{ "id": "sync-status", "title": "AT Protocol", "size": "third" }], + }, +} diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index e0ea0c9f8..7bc8dfc16 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -9,12 +9,9 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, - "./sandbox": "./dist/sandbox-entry.mjs" + "./sandbox": "./dist/plugin.mjs" }, - "files": [ - "dist", - "src" - ], + "files": ["dist", "emdash-plugin.jsonc"], "keywords": [ "emdash", "cms", @@ -31,11 +28,15 @@ "emdash": "workspace:>=0.10.0" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", + "jsonc-parser": "catalog:", "tsdown": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", + "dev": "emdash-plugin dev", "test": "vitest run", "typecheck": "tsgo --noEmit" }, @@ -43,6 +44,5 @@ "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/plugins/atproto" - }, - "dependencies": {} + } } diff --git a/packages/plugins/atproto/src/index.ts b/packages/plugins/atproto/src/index.ts deleted file mode 100644 index 9555e9091..000000000 --- a/packages/plugins/atproto/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * AT Protocol / standard.site Plugin for EmDash CMS - * - * Syndicates published content to the AT Protocol network using the - * standard.site lexicons, with optional cross-posting to Bluesky. - * - * Features: - * - Creates site.standard.publication record (one per site) - * - Creates site.standard.document records on publish - * - Optional Bluesky cross-post with link card - * - Automatic injection via page:metadata - * - Sync status tracking in plugin storage - * - * Designed for sandboxed execution: - * - All HTTP via ctx.http.fetch() - * - Block Kit admin UI (no React components) - * - Capabilities: content:read, network:request:unrestricted - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -// ── Descriptor ────────────────────────────────────────────────── - -/** - * Create the AT Protocol plugin descriptor. - * Import this in your astro.config.mjs / live.config.ts. - */ -export function atprotoPlugin(): PluginDescriptor { - return { - id: "atproto", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-atproto/sandbox", - capabilities: ["content:read", "network:request:unrestricted"], - storage: { - records: { indexes: ["contentId", "status", "lastSyncedAt"] }, - }, - // Block Kit admin pages (no adminEntry needed -- sandboxed) - adminPages: [{ path: "/status", label: "AT Protocol", icon: "globe" }], - adminWidgets: [{ id: "sync-status", title: "AT Protocol", size: "third" }], - }; -} diff --git a/packages/plugins/atproto/src/sandbox-entry.ts b/packages/plugins/atproto/src/plugin.ts similarity index 99% rename from packages/plugins/atproto/src/sandbox-entry.ts rename to packages/plugins/atproto/src/plugin.ts index d5e4ffd81..122a7a6b0 100644 --- a/packages/plugins/atproto/src/sandbox-entry.ts +++ b/packages/plugins/atproto/src/plugin.ts @@ -6,8 +6,7 @@ * bluesky.ts, and standard-site.ts into a single self-contained file. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; import { getAdminPageTarget, type AdminInteraction } from "./admin-routing.js"; import { @@ -327,7 +326,7 @@ async function syndicatePublishedContent( // ── Plugin definition ─────────────────────────────────────────── -export default definePlugin({ +export default { hooks: { "plugin:install": async (_event: unknown, ctx: PluginContext) => { ctx.log.info("AT Protocol plugin installed"); @@ -505,7 +504,7 @@ export default definePlugin({ }, admin: { - handler: async (routeCtx: any, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as AdminInteraction | undefined; const interactionType = interaction?.type ?? "page_load"; const pageTarget = getAdminPageTarget(interaction); @@ -525,7 +524,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit admin helpers ───────────────────────────────────── diff --git a/packages/plugins/atproto/tests/sandbox-entry.test.ts b/packages/plugins/atproto/tests/plugin-runtime.test.ts similarity index 91% rename from packages/plugins/atproto/tests/sandbox-entry.test.ts rename to packages/plugins/atproto/tests/plugin-runtime.test.ts index 907be5af1..173dcbb21 100644 --- a/packages/plugins/atproto/tests/sandbox-entry.test.ts +++ b/packages/plugins/atproto/tests/plugin-runtime.test.ts @@ -29,7 +29,7 @@ function createCtx() { describe("sandbox hooks", () => { it("does not create syndication records from afterSave when published content has not been synced", async () => { - const { default: plugin } = await import("../src/sandbox-entry.js"); + const { default: plugin } = await import("../src/plugin.js"); const ctx = createCtx(); const handler = (plugin as any).hooks["content:afterSave"].handler; @@ -53,7 +53,7 @@ describe("sandbox hooks", () => { }); it("does not syndicate pages by default", async () => { - const { default: plugin } = await import("../src/sandbox-entry.js"); + const { default: plugin } = await import("../src/plugin.js"); const ctx = createCtx(); const handler = (plugin as any).hooks["content:afterPublish"].handler; @@ -76,7 +76,7 @@ describe("sandbox hooks", () => { }); it("does not expose standard.site metadata for pages by default", async () => { - const { default: plugin } = await import("../src/sandbox-entry.js"); + const { default: plugin } = await import("../src/plugin.js"); const ctx = createCtx(); ctx.storage.records.get.mockResolvedValueOnce({ atUri: "at://did:example/site.standard.document/abc", diff --git a/packages/plugins/atproto/tests/plugin.test.ts b/packages/plugins/atproto/tests/plugin.test.ts index dd45bc7dc..927959290 100644 --- a/packages/plugins/atproto/tests/plugin.test.ts +++ b/packages/plugins/atproto/tests/plugin.test.ts @@ -1,44 +1,75 @@ -import { describe, it, expect } from "vitest"; +/** + * Manifest assertions for the AT Protocol plugin. + * + * The redesigned sandboxed-plugin layout puts identity, trust contract, + * and admin surface in `emdash-plugin.jsonc` (the source of truth) and + * leaves `src/plugin.ts` for runtime code only. This test snapshots the + * manifest's structural shape so a refactor can't silently change the + * published trust contract or admin surface. + */ + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { parse as parseJsonc } from "jsonc-parser"; +import { describe, expect, it } from "vitest"; import { version } from "../package.json"; -import { atprotoPlugin } from "../src/index.js"; - -describe("atprotoPlugin descriptor", () => { - it("returns a valid PluginDescriptor", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.id).toBe("atproto"); - expect(descriptor.version).toBe(version); - expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-atproto/sandbox"); - expect(descriptor.adminPages).toHaveLength(1); - expect(descriptor.adminWidgets).toHaveLength(1); + +const MANIFEST_PATH = fileURLToPath(new URL("../emdash-plugin.jsonc", import.meta.url)); + +interface Manifest { + slug: string; + version: string; + publisher: string; + capabilities: string[]; + allowedHosts: string[]; + storage: Record; + admin: { + pages: Array<{ path: string; label: string; icon?: string }>; + widgets: Array<{ id: string; title?: string; size?: string }>; + }; +} + +async function loadManifest(): Promise { + const source = await readFile(MANIFEST_PATH, "utf8"); + const errors: import("jsonc-parser").ParseError[] = []; + const value: unknown = parseJsonc(source, errors, { + allowTrailingComma: true, + disallowComments: false, }); + if (errors.length > 0) { + throw new Error(`Manifest parse failed: ${JSON.stringify(errors)}`); + } + return value as Manifest; +} - it("uses standard format", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.format).toBe("standard"); +describe("atproto plugin manifest", () => { + it("declares the expected identity", async () => { + const manifest = await loadManifest(); + expect(manifest.slug).toBe("atproto"); + expect(manifest.version).toBe(version); }); - it("declares required capabilities", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.capabilities).toContain("content:read"); - expect(descriptor.capabilities).toContain("network:request:unrestricted"); + it("declares the required capabilities", async () => { + const manifest = await loadManifest(); + expect(manifest.capabilities).toContain("content:read"); + expect(manifest.capabilities).toContain("network:request:unrestricted"); }); - it("declares the storage used by the sandbox implementation", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.storage).toHaveProperty("records"); - expect(descriptor.storage!.records!.indexes).toContain("contentId"); - expect(descriptor.storage!.records!.indexes).toContain("status"); - expect(descriptor.storage!.records!.indexes).toContain("lastSyncedAt"); + it("declares the storage used by the runtime", async () => { + const manifest = await loadManifest(); + expect(manifest.storage).toHaveProperty("records"); + expect(manifest.storage.records.indexes).toContain("contentId"); + expect(manifest.storage.records.indexes).toContain("status"); + expect(manifest.storage.records.indexes).toContain("lastSyncedAt"); }); - it("exposes an admin status page and widget", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.adminPages).toEqual([ - { path: "/status", label: "AT Protocol", icon: "globe" }, - ]); - expect(descriptor.adminWidgets).toEqual([ - { id: "sync-status", title: "AT Protocol", size: "third" }, - ]); + it("declares the admin pages and widgets", async () => { + const manifest = await loadManifest(); + expect(manifest.admin.pages).toHaveLength(1); + expect(manifest.admin.pages[0]?.path).toBe("/status"); + expect(manifest.admin.widgets).toHaveLength(1); + expect(manifest.admin.widgets[0]?.id).toBe("sync-status"); }); }); diff --git a/packages/plugins/audit-log/emdash-plugin.jsonc b/packages/plugins/audit-log/emdash-plugin.jsonc new file mode 100644 index 000000000..32f779f9f --- /dev/null +++ b/packages/plugins/audit-log/emdash-plugin.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "audit-log", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Tracks content and media changes (create / update / delete) for compliance and debugging.", + + // Trust contract. Reads content to capture before-state in + // update audit entries; declares the `entries` storage collection + // where audit log lives. No outbound network. + "capabilities": ["content:read"], + "allowedHosts": [], + "storage": { + "entries": { "indexes": ["timestamp", "action", "resourceType", "collection"] }, + }, + + "admin": { + "pages": [{ "path": "/history", "label": "Audit History", "icon": "history" }], + "widgets": [{ "id": "recent-activity", "title": "Recent Activity", "size": "half" }], + }, +} diff --git a/packages/plugins/audit-log/package.json b/packages/plugins/audit-log/package.json index bc5ec887a..95008b300 100644 --- a/packages/plugins/audit-log/package.json +++ b/packages/plugins/audit-log/package.json @@ -9,34 +9,25 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, - "./sandbox": "./dist/sandbox-entry.mjs" + "./sandbox": "./dist/plugin.mjs" }, - "files": [ - "dist" - ], - "keywords": [ - "emdash", - "cms", - "plugin", - "audit", - "logging", - "history" - ], + "files": ["dist", "emdash-plugin.jsonc"], + "keywords": ["emdash", "cms", "plugin", "audit", "logging", "history"], "author": "Matt Kane", "license": "MIT", "peerDependencies": { "emdash": "workspace:>=0.10.0" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:" }, "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", + "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, - "optionalDependencies": {}, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/audit-log/src/index.ts b/packages/plugins/audit-log/src/index.ts deleted file mode 100644 index 717ab0d78..000000000 --- a/packages/plugins/audit-log/src/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Audit Log Plugin for EmDash CMS - * - * Tracks all content and media changes for compliance and debugging. - * - * Features: - * - Logs create, update, delete operations - * - Tracks before/after state for updates - * - Records user information (when available) - * - Provides admin UI for viewing audit history - * - Configurable retention period (admin settings) - * - Uses plugin storage for persistent audit trail - * - * Demonstrates: - * - Plugin storage with indexes and queries - * - Admin-configurable settings schema - * - Lifecycle hooks (install, activate, deactivate, uninstall) - * - content:afterDelete hook - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -export interface AuditEntry { - timestamp: string; - action: "create" | "update" | "delete" | "media:upload" | "media:delete"; - collection?: string; - resourceId: string; - resourceType: "content" | "media"; - userId?: string; - changes?: { - before?: Record; - after?: Record; - }; - metadata?: Record; -} - -/** - * Create the audit log plugin descriptor - */ -export function auditLogPlugin(): PluginDescriptor { - return { - id: "audit-log", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-audit-log/sandbox", - capabilities: ["content:read"], - storage: { - entries: { indexes: ["timestamp", "action", "resourceType", "collection"] }, - }, - adminPages: [{ path: "/history", label: "Audit History", icon: "history" }], - adminWidgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }], - }; -} diff --git a/packages/plugins/audit-log/src/sandbox-entry.ts b/packages/plugins/audit-log/src/plugin.ts similarity index 84% rename from packages/plugins/audit-log/src/sandbox-entry.ts rename to packages/plugins/audit-log/src/plugin.ts index 626cc1ad7..9d2e7de3f 100644 --- a/packages/plugins/audit-log/src/sandbox-entry.ts +++ b/packages/plugins/audit-log/src/plugin.ts @@ -10,28 +10,7 @@ * degradation -- the entry is still recorded). */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -interface ContentSaveEvent { - content: Record & { - id?: string | number; - slug?: string; - status?: string; - data?: Record; - }; - collection: string; - isNew: boolean; -} - -interface ContentDeleteEvent { - id: string; - collection: string; -} - -interface MediaUploadEvent { - media: { id: string }; -} +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface AuditEntry { timestamp: string; @@ -53,6 +32,18 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Coerce a content `id` to a string for use as a cache key or storage + * lookup. Accepts strings and numbers (the canonical ID types); + * everything else (objects, nulls, undefineds) becomes an empty string + * so the caller's existence check (`if (contentId)`) skips the entry. + */ +function stringifyId(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return ""; +} + function isAuditEntry(value: unknown): value is AuditEntry { return ( isRecord(value) && @@ -69,29 +60,28 @@ const beforeSaveCache = new Map(); // ── Plugin definition ── -export default definePlugin({ +export default { hooks: { - "plugin:install": async (_event: unknown, ctx: PluginContext) => { + "plugin:install": async (_event, ctx) => { ctx.log.info("Audit log plugin installed"); }, - "plugin:activate": async (_event: unknown, ctx: PluginContext) => { + "plugin:activate": async (_event, ctx) => { ctx.log.info("Audit log plugin activated"); }, - "plugin:deactivate": async (_event: unknown, ctx: PluginContext) => { + "plugin:deactivate": async (_event, ctx) => { ctx.log.info("Audit log plugin deactivated"); }, - "plugin:uninstall": async (_event: unknown, ctx: PluginContext) => { + "plugin:uninstall": async (_event, ctx) => { ctx.log.info("Audit log plugin uninstalled"); }, "content:beforeSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { - if (!event.isNew && event.content.id) { - const contentId = - typeof event.content.id === "string" ? event.content.id : String(event.content.id); + handler: async (event, ctx) => { + const contentId = stringifyId(event.content.id); + if (!event.isNew && contentId) { try { if (ctx.content) { const existing = await ctx.content.get(event.collection, contentId); @@ -108,9 +98,8 @@ export default definePlugin({ }, "content:afterSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { - const contentId = - typeof event.content.id === "string" ? event.content.id : String(event.content.id ?? ""); + handler: async (event, ctx) => { + const contentId = stringifyId(event.content.id); const cacheKey = `${event.collection}:${contentId}`; const before = beforeSaveCache.get(cacheKey); beforeSaveCache.delete(cacheKey); @@ -141,7 +130,7 @@ export default definePlugin({ }, "content:beforeDelete": { - handler: async (event: ContentDeleteEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { if (ctx.content) { try { const existing = await ctx.content.get(event.collection, event.id); @@ -157,7 +146,7 @@ export default definePlugin({ }, "content:afterDelete": { - handler: async (event: ContentDeleteEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const cacheKey = `delete:${event.collection}:${event.id}`; const beforeData = beforeSaveCache.get(cacheKey); beforeSaveCache.delete(cacheKey); @@ -183,7 +172,7 @@ export default definePlugin({ }, "media:afterUpload": { - handler: async (event: MediaUploadEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const entry: AuditEntry = { timestamp: new Date().toISOString(), action: "media:upload", @@ -205,10 +194,7 @@ export default definePlugin({ routes: { // Block Kit admin handler -- returns plain block objects (no @emdash-cms/blocks import needed) admin: { - handler: async ( - routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as { type: string; page?: string; @@ -230,10 +216,7 @@ export default definePlugin({ }, recent: { - handler: async ( - _routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (_routeCtx, ctx) => { try { const result = await ctx.storage.entries!.query({ orderBy: { timestamp: "desc" }, @@ -255,10 +238,7 @@ export default definePlugin({ }, history: { - handler: async ( - routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (routeCtx, ctx) => { try { const url = new URL(routeCtx.request.url); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 100); @@ -286,7 +266,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit helpers (plain objects, no @emdash-cms/blocks import) ── diff --git a/packages/plugins/marketplace-test/emdash-plugin.jsonc b/packages/plugins/marketplace-test/emdash-plugin.jsonc new file mode 100644 index 000000000..30dd5465c --- /dev/null +++ b/packages/plugins/marketplace-test/emdash-plugin.jsonc @@ -0,0 +1,19 @@ +{ + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "marketplace-test", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Test plugin for end-to-end registry publishing and audit workflow testing.", + + // Trust contract. Reads and writes content; declared `events` + // storage collection. No outbound network. + "capabilities": ["content:read", "content:write"], + "allowedHosts": [], + "storage": { + "events": { "indexes": ["timestamp", "type"] }, + }, +} diff --git a/packages/plugins/marketplace-test/package.json b/packages/plugins/marketplace-test/package.json index ab7c23ad0..5c7c964d8 100644 --- a/packages/plugins/marketplace-test/package.json +++ b/packages/plugins/marketplace-test/package.json @@ -2,7 +2,7 @@ "name": "@emdash-cms/plugin-marketplace-test", "private": true, "version": "0.1.2", - "description": "Test plugin for end-to-end marketplace publishing and audit workflow testing", + "description": "Test plugin for end-to-end registry publishing and audit workflow testing", "type": "module", "main": "dist/index.mjs", "exports": { @@ -10,29 +10,22 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, - "./sandbox": "./dist/sandbox-entry.mjs" + "./sandbox": "./dist/plugin.mjs" }, - "files": [ - "dist" - ], + "files": ["dist", "emdash-plugin.jsonc"], "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", + "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, - "keywords": [ - "emdash", - "cms", - "plugin", - "test", - "marketplace" - ], + "keywords": ["emdash", "cms", "plugin", "test", "marketplace"], "author": "Matt Kane", "license": "MIT", "dependencies": { "emdash": "workspace:*" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:" }, diff --git a/packages/plugins/marketplace-test/src/index.ts b/packages/plugins/marketplace-test/src/index.ts deleted file mode 100644 index 99f0e4179..000000000 --- a/packages/plugins/marketplace-test/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Marketplace Test Plugin for EmDash CMS - * - * A self-contained plugin designed for end-to-end testing of the marketplace - * publish → audit → approval pipeline. Includes: - * - Backend sandbox code (content:beforeSave hook) - * - Icon and screenshot assets - * - Full manifest with capabilities - * - * Usage: - * emdash plugin bundle --dir packages/plugins/marketplace-test - * emdash plugin publish dist/marketplace-test-0.1.0.tar.gz --registry - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -/** - * Plugin factory -- returns a descriptor for the integration. - */ -export function marketplaceTestPlugin(): PluginDescriptor { - return { - id: "marketplace-test", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-marketplace-test/sandbox", - capabilities: ["content:read", "content:write"], - allowedHosts: [], - storage: { - events: { indexes: ["timestamp", "type"] }, - }, - }; -} - -export default marketplaceTestPlugin; diff --git a/packages/plugins/marketplace-test/src/plugin.ts b/packages/plugins/marketplace-test/src/plugin.ts new file mode 100644 index 000000000..f62bb82fe --- /dev/null +++ b/packages/plugins/marketplace-test/src/plugin.ts @@ -0,0 +1,55 @@ +/** + * Marketplace Test Plugin for EmDash CMS — sandbox entry. + * + * Self-contained plugin for end-to-end testing of the registry publish + * → audit → install pipeline. Exercises the three primitives a real + * sandboxed plugin uses: a hook (`content:beforeSave`), routes + * (`ping`, `events`), and a storage collection (`events`). + * + * Identity (id, version), the trust contract (capabilities, + * allowedHosts, storage), and the rest of the metadata live in + * `emdash-plugin.jsonc`. This file holds runtime behaviour only. + */ + +import type { SandboxedPlugin } from "emdash/plugin"; + +export default { + hooks: { + "content:beforeSave": { + handler: async (event, ctx) => { + ctx.log.info("[marketplace-test] beforeSave fired", { + collection: event.collection, + isNew: event.isNew, + }); + + // Record execution in storage so the registry's install + // audit can verify the hook actually ran post-install. + await ctx.storage.events!.put(`hook-${Date.now()}`, { + timestamp: new Date().toISOString(), + type: "content:beforeSave", + collection: event.collection, + isNew: event.isNew, + }); + + return event.content; + }, + }, + }, + + routes: { + ping: { + handler: async (_routeCtx, ctx) => ({ + pong: true, + pluginId: ctx.plugin.id, + timestamp: Date.now(), + }), + }, + + events: { + handler: async (_routeCtx, ctx) => { + const result = await ctx.storage.events!.query({ limit: 10 }); + return { count: result.items.length, items: result.items }; + }, + }, + }, +} satisfies SandboxedPlugin; diff --git a/packages/plugins/marketplace-test/src/sandbox-entry.ts b/packages/plugins/marketplace-test/src/sandbox-entry.ts deleted file mode 100644 index 38be1b135..000000000 --- a/packages/plugins/marketplace-test/src/sandbox-entry.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Sandbox Entry Point - * - * Canonical plugin implementation using the standard format. - * Runs in both trusted (in-process) and sandboxed (isolate) modes. - */ - -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -interface HookEvent { - content?: Record; - collection?: string; - isNew?: boolean; -} - -export default definePlugin({ - hooks: { - "content:beforeSave": { - handler: async (event: HookEvent, ctx: PluginContext) => { - ctx.log.info("[marketplace-test] beforeSave fired", { - collection: event.collection, - isNew: event.isNew, - }); - - // Record execution in storage - await ctx.storage.events.put(`hook-${Date.now()}`, { - timestamp: new Date().toISOString(), - type: "content:beforeSave", - collection: event.collection, - isNew: event.isNew, - }); - - return event.content; - }, - }, - }, - - routes: { - ping: { - handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => ({ - pong: true, - pluginId: pluginCtx.plugin.id, - timestamp: Date.now(), - }), - }, - - events: { - handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => { - const result = await pluginCtx.storage.events.query({ limit: 10 }); - return { count: result.items.length, items: result.items }; - }, - }, - }, -}); diff --git a/packages/plugins/sandboxed-test/emdash-plugin.jsonc b/packages/plugins/sandboxed-test/emdash-plugin.jsonc new file mode 100644 index 000000000..657d1390d --- /dev/null +++ b/packages/plugins/sandboxed-test/emdash-plugin.jsonc @@ -0,0 +1,27 @@ +{ + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "sandboxed-test", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Test plugin exercising sandboxed-plugin enforcement and feature surface.", + + // Trust contract. Reads content + a single declared storage + // collection. Outbound HTTP restricted to httpbin.org so the + // adversarial-fetch tests have a real allowed host to contrast + // with the disallowed ones. + "capabilities": ["content:read", "network:request"], + "allowedHosts": ["httpbin.org"], + "storage": { + "events": { "indexes": ["timestamp", "type"] }, + }, + + // Admin surface — rendered by the `admin` route handler via Block Kit. + "admin": { + "pages": [{ "path": "/sandbox", "label": "Sandbox Tests", "icon": "shield" }], + "widgets": [{ "id": "sandbox-status", "title": "Sandbox Status", "size": "half" }], + }, +} diff --git a/packages/plugins/sandboxed-test/package.json b/packages/plugins/sandboxed-test/package.json index 2bfecaa51..1a0683f77 100644 --- a/packages/plugins/sandboxed-test/package.json +++ b/packages/plugins/sandboxed-test/package.json @@ -10,34 +10,25 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, - "./sandbox": "./dist/sandbox-entry.mjs" + "./sandbox": "./dist/plugin.mjs" }, - "files": [ - "dist" - ], + "files": ["dist", "emdash-plugin.jsonc"], "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", + "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, - "keywords": [ - "emdash", - "cms", - "plugin", - "test", - "sandbox" - ], + "keywords": ["emdash", "cms", "plugin", "test", "sandbox"], "author": "Matt Kane", "license": "MIT", "dependencies": { "emdash": "workspace:*" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:" }, - "peerDependencies": {}, - "optionalDependencies": {}, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/sandboxed-test/src/index.ts b/packages/plugins/sandboxed-test/src/index.ts deleted file mode 100644 index d08f7d850..000000000 --- a/packages/plugins/sandboxed-test/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Sandboxed Test Plugin for EmDash CMS - * - * Tests the sandboxed plugin system. Designed to run in an isolated - * V8 isolate via Worker Loader. Admin UI uses Block Kit. - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -/** - * Plugin factory - returns a descriptor for the integration - */ -export function sandboxedTestPlugin(): PluginDescriptor { - return { - id: "sandboxed-test", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-sandboxed-test/sandbox", - - adminPages: [{ path: "/sandbox", label: "Sandbox Tests", icon: "shield" }], - adminWidgets: [{ id: "sandbox-status", title: "Sandbox Status", size: "half" }], - - capabilities: ["content:read", "network:request"], - allowedHosts: ["httpbin.org"], - storage: { - events: { indexes: ["timestamp", "type"] }, - }, - }; -} diff --git a/packages/plugins/sandboxed-test/src/sandbox-entry.ts b/packages/plugins/sandboxed-test/src/plugin.ts similarity index 99% rename from packages/plugins/sandboxed-test/src/sandbox-entry.ts rename to packages/plugins/sandboxed-test/src/plugin.ts index 8585da739..3c79d5dd9 100644 --- a/packages/plugins/sandboxed-test/src/sandbox-entry.ts +++ b/packages/plugins/sandboxed-test/src/plugin.ts @@ -5,8 +5,7 @@ * Runs in both trusted (in-process) and sandboxed (isolate) modes. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface HookEvent { content?: Record; @@ -28,7 +27,7 @@ function getString(value: unknown, key: string): string | undefined { // ── Plugin definition ── -export default definePlugin({ +export default { hooks: { "content:beforeSave": { handler: async (event: HookEvent, ctx: PluginContext) => { @@ -830,7 +829,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit admin helpers ── diff --git a/packages/plugins/webhook-notifier/emdash-plugin.jsonc b/packages/plugins/webhook-notifier/emdash-plugin.jsonc new file mode 100644 index 000000000..a9ccf6b25 --- /dev/null +++ b/packages/plugins/webhook-notifier/emdash-plugin.jsonc @@ -0,0 +1,26 @@ +{ + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "webhook-notifier", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Posts to user-configured external URLs when content or media changes.", + + // Trust contract. Outbound HTTP is unrestricted because the + // webhook URLs are user-supplied at runtime — there's no fixed + // allow-list we could pin at publish time. Declares the + // `deliveries` storage collection for audit + retry state. + "capabilities": ["network:request:unrestricted"], + "allowedHosts": [], + "storage": { + "deliveries": { "indexes": ["timestamp", "webhookUrl", "status"] }, + }, + + "admin": { + "pages": [{ "path": "/settings", "label": "Webhook Settings", "icon": "send" }], + "widgets": [{ "id": "status", "title": "Webhooks", "size": "third" }], + }, +} diff --git a/packages/plugins/webhook-notifier/package.json b/packages/plugins/webhook-notifier/package.json index 28e8e9943..377abd0f2 100644 --- a/packages/plugins/webhook-notifier/package.json +++ b/packages/plugins/webhook-notifier/package.json @@ -9,34 +9,25 @@ "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, - "./sandbox": "./dist/sandbox-entry.mjs" + "./sandbox": "./dist/plugin.mjs" }, - "files": [ - "dist" - ], - "keywords": [ - "emdash", - "cms", - "plugin", - "webhook", - "notifications", - "integration" - ], + "files": ["dist", "emdash-plugin.jsonc"], + "keywords": ["emdash", "cms", "plugin", "webhook", "notifications", "integration"], "author": "Matt Kane", "license": "MIT", "peerDependencies": { "emdash": "workspace:>=0.10.0" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", "tsdown": "catalog:", "typescript": "catalog:" }, "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", + "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, - "optionalDependencies": {}, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/webhook-notifier/src/index.ts b/packages/plugins/webhook-notifier/src/index.ts deleted file mode 100644 index a045aeea3..000000000 --- a/packages/plugins/webhook-notifier/src/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Webhook Notifier Plugin for EmDash CMS - * - * Posts to external URLs when content changes occur. - * - * Features: - * - Configurable webhook URLs (admin settings) - * - Secret token for authentication (encrypted) - * - Retry logic with exponential backoff - * - Event filtering by collection and action - * - Manual trigger via API route - * - * Demonstrates: - * - network:request:unrestricted capability (unrestricted outbound for user-configured URLs) - * - settings.secret() for encrypted tokens - * - apiRoutes for custom endpoints - * - content:afterDelete hook - * - Hook dependencies (runs after audit-log) - * - errorPolicy: "continue" (don't block save on webhook failure) - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -export interface WebhookPayload { - event: "content:create" | "content:update" | "content:delete" | "media:upload"; - timestamp: string; - collection?: string; - resourceId: string; - resourceType: "content" | "media"; - data?: Record; - metadata?: Record; -} - -/** - * Create the webhook notifier plugin descriptor - */ -export function webhookNotifierPlugin(): PluginDescriptor { - return { - id: "webhook-notifier", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-webhook-notifier/sandbox", - capabilities: ["network:request:unrestricted"], - storage: { - deliveries: { indexes: ["timestamp", "webhookUrl", "status"] }, - }, - adminPages: [{ path: "/settings", label: "Webhook Settings", icon: "send" }], - adminWidgets: [{ id: "status", title: "Webhooks", size: "third" }], - }; -} diff --git a/packages/plugins/webhook-notifier/src/sandbox-entry.ts b/packages/plugins/webhook-notifier/src/plugin.ts similarity index 94% rename from packages/plugins/webhook-notifier/src/sandbox-entry.ts rename to packages/plugins/webhook-notifier/src/plugin.ts index 1a96bd3c4..e01faeb8f 100644 --- a/packages/plugins/webhook-notifier/src/sandbox-entry.ts +++ b/packages/plugins/webhook-notifier/src/plugin.ts @@ -5,23 +5,7 @@ * Runs in both trusted (in-process) and sandboxed (isolate) modes. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -interface ContentSaveEvent { - content: Record; - collection: string; - isNew: boolean; -} - -interface ContentDeleteEvent { - id: string; - collection: string; -} - -interface MediaUploadEvent { - media: { id: string }; -} +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface WebhookPayload { event: string; @@ -173,14 +157,14 @@ function getFetchFn(ctx: PluginContext): FetchFn { // ── Plugin definition ── -export default definePlugin({ +export default { hooks: { "content:afterSave": { priority: 210, timeout: 10000, dependencies: ["audit-log"], errorPolicy: "continue", - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const { url, token, enabled } = await getConfig(ctx); if (enabled === false || !url) return; @@ -208,7 +192,7 @@ export default definePlugin({ timeout: 10000, dependencies: ["audit-log"], errorPolicy: "continue", - handler: async (event: ContentDeleteEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const { url, token, enabled } = await getConfig(ctx); if (enabled === false || !url) return; @@ -228,7 +212,7 @@ export default definePlugin({ priority: 210, timeout: 10000, errorPolicy: "continue", - handler: async (event: MediaUploadEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const { url, token, enabled } = await getConfig(ctx); if (enabled === false || !url) return; @@ -246,10 +230,7 @@ export default definePlugin({ routes: { admin: { - handler: async ( - routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as { type: string; page?: string; @@ -275,7 +256,7 @@ export default definePlugin({ }, status: { - handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (_routeCtx, ctx) => { try { const url = await ctx.kv.get("settings:webhookUrl"); const enabled = await ctx.kv.get("settings:enabled"); @@ -301,7 +282,7 @@ export default definePlugin({ }, settings: { - handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (_routeCtx, ctx) => { try { const settings = await ctx.kv.list("settings:"); const map: Record = {}; @@ -322,7 +303,7 @@ export default definePlugin({ }, "settings/save": { - handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { try { const input = isRecord(routeCtx.input) ? routeCtx.input : {}; if (typeof input.webhookUrl === "string") @@ -341,7 +322,7 @@ export default definePlugin({ }, test: { - handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const testUrl = getString(routeCtx.input, "url"); if (!testUrl) return { success: false, error: "No webhook URL provided" }; @@ -372,7 +353,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit admin helpers ── diff --git a/packages/registry-cli/README.md b/packages/registry-cli/README.md deleted file mode 100644 index 3202c9e36..000000000 --- a/packages/registry-cli/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# @emdash-cms/registry-cli - -CLI for the experimental EmDash plugin registry. - -> EXPERIMENTAL: `bundle`, `login`, `whoami`, `switch`, and `publish` all work today against any atproto PDS — `publish` writes profile + release records to the publisher's own repo. The discovery commands (`search`, `info`) need an aggregator; none is deployed yet, so those won't return useful results until one is. NSIDs and shapes will change while RFC 0001 is in flight; pin to an exact version. - -## Installation - -```sh -npx @emdash-cms/registry-cli bundle -``` - -Or install globally: - -```sh -npm install -g @emdash-cms/registry-cli -emdash-registry bundle -``` - -## Commands - -```text -emdash-registry login Interactive atproto OAuth login -emdash-registry logout [--did ] Revoke the active session -emdash-registry whoami Show stored sessions -emdash-registry switch Switch the active publisher session -emdash-registry search Free-text search -emdash-registry info Show package details -emdash-registry bundle Bundle a plugin source dir into a tarball -emdash-registry publish --url Publish a release that points at a hosted tarball -emdash-registry validate [path] Validate emdash-plugin.jsonc against the v1 schema -``` - -All commands accept `--json`. Discovery commands accept `--aggregator ` (or `EMDASH_REGISTRY_URL`). - -## Publishing - -Three steps. The CLI does not host artifacts — you do, anywhere public. - -```sh -emdash-registry bundle -# upload dist/-.tar.gz somewhere public -emdash-registry publish --url https://example.com/foo-1.0.0.tar.gz -``` - -On first publish, pass `--license` and `--security-email` (or `--security-url`) to bootstrap the package profile — or keep them in `emdash-plugin.jsonc` (see below). - -## `emdash-plugin.jsonc` - -Drop an `emdash-plugin.jsonc` file next to your plugin's `package.json` to declare profile fields once instead of passing them on every publish. The CLI reads it automatically from the current directory. Schema-driven IDE completion works via the bundled JSON Schema: - -```jsonc -{ - "$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", - - "license": "MIT", - "author": { "name": "Jane Doe", "url": "https://example.com" }, - "security": { "email": "security@example.com" }, - - // Optional - "name": "Gallery", - "description": "Image gallery block for EmDash.", - "keywords": ["gallery", "images"], - "repo": "https://github.com/example/plugin-gallery", -} -``` - -The file is JSONC: comments and trailing commas are allowed. Use `authors: [...]` and `securityContacts: [...]` for multi-author or multi-contact plugins. - -### Publisher pinning - -After your first successful publish, the CLI writes the active session's DID back into the manifest as `publisher`: - -```jsonc -{ - "license": "MIT", - "publisher": "did:plc:abc123def456", - ... -} -``` - -On every subsequent publish, the CLI verifies the active session matches the pinned `publisher`. If they don't match, publish refuses with `MANIFEST_PUBLISHER_MISMATCH` so you can't accidentally publish under the wrong account. To resolve a mismatch, either: - -- switch sessions: `emdash-registry switch ` -- update the manifest if you're transferring the plugin to a new publisher - -**DIDs are the identity, not handles.** Internally the CLI always compares the active session's DID against the pinned publisher's DID. If you pin a handle (`"publisher": "example.com"`), the CLI resolves it to a DID at publish time and compares against that — so a handle pin is just a friendlier alias for the underlying DID. Handles are mutable: if the publisher's domain changes ownership and the resolver later points at a different DID, the publish will refuse. DIDs are durable and the recommended pin for long-lived plugins. - -Validate without publishing: - -```sh -emdash-registry validate -``` - -CLI flags (`--license`, `--author-name`, …) still win over manifest values when both are set, which is useful in CI. Pass `--no-manifest` to skip the manifest entirely. - -## Programmatic API - -```ts -import { bundlePlugin } from "@emdash-cms/registry-cli"; - -const result = await bundlePlugin({ dir: "./my-plugin" }); -``` - -For discovery and credentials, import from `@emdash-cms/registry-client`. diff --git a/packages/registry-cli/schemas/emdash-plugin.schema.json b/packages/registry-cli/schemas/emdash-plugin.schema.json deleted file mode 100644 index 2615dcbe0..000000000 --- a/packages/registry-cli/schemas/emdash-plugin.schema.json +++ /dev/null @@ -1,204 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://emdashcms.com/schemas/emdash-plugin.schema.json", - "title": "EmDash plugin manifest", - "description": "Hand-authored manifest for publishing a plugin to the EmDash plugin registry. Lives next to the plugin's `package.json` as `emdash-plugin.jsonc`.", - "type": "object", - "properties": { - "$schema": { - "$ref": "#/$defs/__schema0" - }, - "license": { - "$ref": "#/$defs/__schema1" - }, - "publisher": { - "$ref": "#/$defs/__schema2" - }, - "author": { - "$ref": "#/$defs/__schema3" - }, - "authors": { - "$ref": "#/$defs/__schema8" - }, - "security": { - "$ref": "#/$defs/__schema9" - }, - "securityContacts": { - "$ref": "#/$defs/__schema13" - }, - "name": { - "$ref": "#/$defs/__schema14" - }, - "description": { - "$ref": "#/$defs/__schema15" - }, - "keywords": { - "$ref": "#/$defs/__schema16" - }, - "repo": { - "$ref": "#/$defs/__schema18" - } - }, - "required": [ - "license" - ], - "additionalProperties": false, - "$defs": { - "__schema0": { - "type": "string", - "description": "Path or URL to the JSON Schema describing this file. Editors use this for completion and validation." - }, - "__schema1": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "title": "License", - "description": "SPDX license expression (e.g. \"MIT\", \"Apache-2.0\", \"MIT OR Apache-2.0\"). Required on first publish; ignored on subsequent publishes (the existing profile wins).", - "examples": [ - "MIT", - "Apache-2.0", - "MIT OR Apache-2.0" - ] - }, - "__schema2": { - "type": "string", - "title": "Publisher", - "description": "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", - "examples": [ - "did:plc:abc123def456", - "example.com" - ] - }, - "__schema3": { - "$ref": "#/$defs/__schema4" - }, - "__schema4": { - "type": "object", - "properties": { - "name": { - "$ref": "#/$defs/__schema5" - }, - "url": { - "$ref": "#/$defs/__schema6" - }, - "email": { - "$ref": "#/$defs/__schema7" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "title": "Author", - "description": "A single author entry. Mirrors the lexicon's author shape." - }, - "__schema5": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Display name." - }, - "__schema6": { - "type": "string", - "maxLength": 1024, - "format": "uri", - "description": "Author's homepage or profile URL. Either this or `email` is recommended." - }, - "__schema7": { - "type": "string", - "maxLength": 256, - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Author's contact email. Either this or `url` is recommended." - }, - "__schema8": { - "minItems": 1, - "maxItems": 32, - "type": "array", - "items": { - "$ref": "#/$defs/__schema4" - }, - "title": "Authors (multiple)", - "description": "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one." - }, - "__schema9": { - "$ref": "#/$defs/__schema10" - }, - "__schema10": { - "type": "object", - "properties": { - "url": { - "$ref": "#/$defs/__schema11" - }, - "email": { - "$ref": "#/$defs/__schema12" - } - }, - "additionalProperties": false, - "title": "Security contact", - "description": "A single security contact. At least one of `url` or `email` must be present." - }, - "__schema11": { - "type": "string", - "maxLength": 1024, - "format": "uri", - "description": "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required." - }, - "__schema12": { - "type": "string", - "maxLength": 256, - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Security contact email. Either this or `url` is required." - }, - "__schema13": { - "minItems": 1, - "maxItems": 8, - "type": "array", - "items": { - "$ref": "#/$defs/__schema10" - }, - "title": "Security contacts (multiple)", - "description": "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one." - }, - "__schema14": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "title": "Display name", - "description": "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted." - }, - "__schema15": { - "type": "string", - "minLength": 1, - "maxLength": 1024, - "title": "Description", - "description": "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists." - }, - "__schema16": { - "maxItems": 5, - "type": "array", - "items": { - "$ref": "#/$defs/__schema17" - }, - "title": "Keywords", - "description": "Search keywords (<= 5 entries, FAIR convention)." - }, - "__schema17": { - "type": "string", - "minLength": 1, - "maxLength": 128 - }, - "__schema18": { - "type": "string", - "maxLength": 1024, - "format": "uri", - "pattern": "^https:\\/\\/", - "title": "Source repository", - "description": "HTTPS URL of the plugin's source repository. Surfaced in registry listings.", - "examples": [ - "https://github.com/emdash-cms/plugin-gallery" - ] - } - } -} diff --git a/packages/registry-cli/src/bundle/api.ts b/packages/registry-cli/src/bundle/api.ts deleted file mode 100644 index 47d63c7b0..000000000 --- a/packages/registry-cli/src/bundle/api.ts +++ /dev/null @@ -1,945 +0,0 @@ -/** - * Programmatic plugin-bundling API. - * - * Pure-ish core of the bundling pipeline -- no `process.exit`, no console - * output. The CLI in `./command.ts` is a thin wrapper that turns these calls - * into pretty terminal output; tests exercise this module directly. - * - * The bundling steps: - * - * 1. Resolve plugin entrypoints from the user's `package.json`. - * 2. Build the main entry with `tsdown` and dynamically import it to - * extract a `ResolvedPlugin` (descriptor factory or `createPlugin`). - * 3. If a sandbox entry exists, build it twice — once as a probe to - * capture hook/route names for the manifest, once as the final - * `backend.js` (minified, with `emdash` aliased to a no-op shim). - * 4. Build `admin.js` if an admin entry is declared. - * 5. Write `manifest.json` and copy assets (README, icon, screenshots). - * 6. Validate (size limits, no Node builtins, no source exports, admin - * route consistency, sandbox-incompatible features). - * 7. Create the gzipped tarball and return its checksum. - * - * Failures throw `BundleError` with a structured `code` so callers can - * branch (CLI shows a helpful message; tests assert the code). - */ - -import { createHash } from "node:crypto"; -import { - copyFile, - mkdir, - mkdtemp, - readdir, - readFile, - rm, - stat, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, extname, join, resolve } from "node:path"; - -import { - CAPABILITY_RENAMES, - isDeprecatedCapability, - type PluginManifest, - type ResolvedPlugin, -} from "./types.js"; -import { - collectBundleEntries, - createTarball, - extractManifest, - fileExists, - findBuildOutput, - findNodeBuiltinImports, - findSourceExports, - formatBytes, - ICON_SIZE, - MAX_SCREENSHOTS, - MAX_SCREENSHOT_HEIGHT, - MAX_SCREENSHOT_WIDTH, - readImageDimensions, - resolveSourceEntry, - totalBundleBytes, - validateBundleSize, -} from "./utils.js"; - -const TS_EXT_RE = /\.(tsx?|[mc]?js)$/; -const SLASH_RE = /\//g; -const LEADING_AT_RE = /^@/; -const EMDASH_SCOPE_RE = /^@emdash-cms\//; - -// ────────────────────────────────────────────────────────────────────────── -// Public types -// ────────────────────────────────────────────────────────────────────────── - -export type BundleErrorCode = - | "MISSING_PACKAGE_JSON" - | "MISSING_ENTRYPOINT" - | "MAIN_BUILD_FAILED" - | "INVALID_PLUGIN_FORMAT" - | "TRUSTED_ONLY_FEATURE" - | "BACKEND_BUILD_FAILED" - | "VALIDATION_FAILED"; - -export class BundleError extends Error { - readonly code: BundleErrorCode; - - constructor(code: BundleErrorCode, message: string) { - super(message); - this.name = "BundleError"; - this.code = code; - } -} - -export interface BundleLogger { - start?(message: string): void; - info?(message: string): void; - success?(message: string): void; - warn?(message: string): void; -} - -export interface BundleOptions { - /** Plugin source directory, must contain a `package.json`. */ - dir: string; - /** - * Output directory for the tarball, relative to `dir` if not absolute. - * Defaults to `/dist`. - */ - outDir?: string; - /** - * Skip tarball creation; only run the build + validation. Useful for - * pre-publish checks. Default: `false`. - */ - validateOnly?: boolean; - /** - * Optional progress reporter. The CLI passes a consola-shaped adapter; - * tests typically pass `undefined` or a recording stub. - */ - logger?: BundleLogger; -} - -export interface BundleResult { - /** The extracted plugin manifest (also written to manifest.json). */ - manifest: PluginManifest; - /** Absolute path to the resulting tarball, or `null` when `validateOnly`. */ - tarballPath: string | null; - /** Tarball size in bytes, or `null` when `validateOnly`. */ - tarballBytes: number | null; - /** Hex sha256 of the tarball contents, or `null` when `validateOnly`. */ - sha256: string | null; - /** Non-fatal warnings collected during validation (deprecated caps, etc.). */ - warnings: string[]; -} - -// ────────────────────────────────────────────────────────────────────────── -// Implementation -// ────────────────────────────────────────────────────────────────────────── - -interface ResolvedEntries { - mainEntry: string; - backendEntry: string | undefined; - adminEntry: string | undefined; - pkg: PackageJson; -} - -interface PackageJson { - name?: string; - main?: string; - exports?: Record; -} - -export async function bundlePlugin(options: BundleOptions): Promise { - const log = options.logger ?? {}; - const pluginDir = resolve(options.dir); - const outDir = resolve(pluginDir, options.outDir ?? "dist"); - const validateOnly = options.validateOnly ?? false; - const warnings: string[] = []; - const warn = (msg: string) => { - warnings.push(msg); - log.warn?.(msg); - }; - - log.start?.(validateOnly ? "Validating plugin..." : "Bundling plugin..."); - - // ── 1. Read package.json and resolve entrypoints ── - const entries = await resolveEntries(pluginDir, log); - - // ── 2. Extract manifest by importing the plugin ── - log.start?.("Extracting plugin manifest..."); - - // Each invocation gets its own tmpdir under the OS tmp root so concurrent - // `bundlePlugin` runs (CI + local dev, watch-mode + manual) don't trample - // each other's intermediate artefacts. Cleaned up unconditionally in the - // `finally` below. - const tmpDir = await mkdtemp(join(tmpdir(), "emdash-bundle-")); - - try { - // Dynamic-import tsdown INSIDE the try block so a missing/broken - // tsdown install (or a transient ENOENT during import) doesn't leak - // the tmpdir we just created. The cost is one extra try-frame; the - // alternative was a tmpdir orphaned per failed import. - const { build } = await import("tsdown"); - - const resolvedPlugin = await extractResolvedPlugin({ - pluginDir, - tmpDir, - entries, - build, - }); - - const manifest = extractManifest(resolvedPlugin); - - // Sandboxed plugins must not declare native-mode-only features. - if (resolvedPlugin.admin?.entry) { - throw new BundleError( - "TRUSTED_ONLY_FEATURE", - "Plugin declares adminEntry — React admin components require native/trusted mode. Use Block Kit for sandboxed admin pages, or remove adminEntry.", - ); - } - if ( - resolvedPlugin.admin?.portableTextBlocks && - resolvedPlugin.admin.portableTextBlocks.length > 0 - ) { - throw new BundleError( - "TRUSTED_ONLY_FEATURE", - "Plugin declares portableTextBlocks — these require native/trusted mode and cannot be bundled for the marketplace.", - ); - } - - log.success?.(`Plugin: ${manifest.id}@${manifest.version}`); - log.info?.( - ` Capabilities: ${manifest.capabilities.length > 0 ? manifest.capabilities.join(", ") : "(none)"}`, - ); - log.info?.( - ` Hooks: ${manifest.hooks.length > 0 ? manifest.hooks.map((h) => (typeof h === "string" ? h : h.name)).join(", ") : "(none)"}`, - ); - log.info?.( - ` Routes: ${manifest.routes.length > 0 ? manifest.routes.map((r) => (typeof r === "string" ? r : r.name)).join(", ") : "(none)"}`, - ); - - // ── 3. Bundle backend.js ── - const bundleDir = join(tmpDir, "bundle"); - await mkdir(bundleDir, { recursive: true }); - - if (entries.backendEntry) { - log.start?.("Bundling backend..."); - const shimPath = await writeEmdashShim(join(tmpDir, "shims")); - - await build({ - config: false, - entry: [entries.backendEntry], - format: "esm", - outDir: join(tmpDir, "backend"), - dts: false, - platform: "neutral", - external: [], - alias: { emdash: shimPath }, - minify: true, - treeshake: true, - }); - - const backendBaseName = basename(entries.backendEntry).replace(TS_EXT_RE, ""); - const backendOutputPath = await findBuildOutput(join(tmpDir, "backend"), backendBaseName); - if (!backendOutputPath) { - throw new BundleError("BACKEND_BUILD_FAILED", "Backend build produced no output"); - } - await copyFile(backendOutputPath, join(bundleDir, "backend.js")); - log.success?.("Built backend.js"); - } else { - warn( - 'No sandbox entry found — bundle will have no backend.js. Add "src/sandbox-entry.ts" or a "./sandbox" export.', - ); - } - - // ── 4. Bundle admin.js ── - if (entries.adminEntry) { - log.start?.("Bundling admin..."); - await build({ - config: false, - entry: [entries.adminEntry], - format: "esm", - outDir: join(tmpDir, "admin"), - dts: false, - platform: "neutral", - external: [], - minify: true, - treeshake: true, - }); - - const adminBaseName = basename(entries.adminEntry).replace(TS_EXT_RE, ""); - const adminOutputPath = await findBuildOutput(join(tmpDir, "admin"), adminBaseName); - if (adminOutputPath) { - await copyFile(adminOutputPath, join(bundleDir, "admin.js")); - log.success?.("Built admin.js"); - } - } - - // ── 5. Write manifest.json ── - await writeFile(join(bundleDir, "manifest.json"), JSON.stringify(manifest, null, 2)); - - // ── 6. Collect assets ── - log.start?.("Collecting assets..."); - await collectAssets({ pluginDir, bundleDir, log, warn }); - - // ── 7. Validate ── - log.start?.("Validating bundle..."); - const validationErrors: string[] = []; - - // Source exports check (npm-published plugins must point at built files). - if (entries.pkg.exports) { - for (const issue of findSourceExports(entries.pkg.exports)) { - validationErrors.push( - `Export "${issue.exportPath}" points to source (${issue.resolvedPath}). Package exports must point to built files (e.g. dist/*.mjs).`, - ); - } - } - - // Node builtins in backend.js -> hard fail. - const backendPath = join(bundleDir, "backend.js"); - if (await fileExists(backendPath)) { - const backendCode = await readFile(backendPath, "utf-8"); - const builtins = findNodeBuiltinImports(backendCode); - if (builtins.length > 0) { - validationErrors.push( - `backend.js imports Node.js built-in modules: ${builtins.join(", ")}. Sandboxed plugins cannot use Node.js APIs.`, - ); - } - } - - // Capability sanity warnings. - const declaresUnrestricted = - manifest.capabilities.includes("network:request:unrestricted") || - manifest.capabilities.includes("network:fetch:any"); - const declaresHostRestricted = - manifest.capabilities.includes("network:request") || - manifest.capabilities.includes("network:fetch"); - if (declaresUnrestricted) { - warn( - "Plugin declares unrestricted network access (network:request:unrestricted) — it can make requests to any host.", - ); - } else if (declaresHostRestricted && manifest.allowedHosts.length === 0) { - // `publish` will hard-fail this case (INVALID_MANIFEST) because - // the lexicon says `request: {}` means "unrestricted" -- silently - // publishing that contradicts the apparent intent of declaring - // `network:request` (host-restricted) with empty allowedHosts. - // Surface it loudly at bundle time so the developer fixes it - // before they try to publish. - warn( - "Plugin declares network:request capability but no allowedHosts. The lexicon treats this as `unrestricted` access. Add specific host patterns to allowedHosts, or upgrade the capability to network:request:unrestricted. `publish` will refuse this combination.", - ); - } - - // Deprecated capabilities are warnings here; `publish` hard-fails on them. - const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability); - if (deprecatedCaps.length > 0) { - warn("Plugin uses deprecated capability names. Rename them before publishing:"); - for (const cap of deprecatedCaps) { - warn(` ${cap} -> ${CAPABILITY_RENAMES[cap]}`); - } - } - - // Trusted-only features that won't work in sandboxed mode. - if ( - resolvedPlugin.admin?.portableTextBlocks && - resolvedPlugin.admin.portableTextBlocks.length > 0 - ) { - warn( - "Plugin declares portableTextBlocks — these require trusted mode and will be ignored in sandboxed plugins.", - ); - } - if (resolvedPlugin.admin?.entry) { - warn( - "Plugin declares admin.entry — custom React components require trusted mode. Use Block Kit for sandboxed admin pages.", - ); - } - if (resolvedPlugin.hooks["page:fragments"]) { - warn( - "Plugin declares page:fragments hook — this is trusted-only and will not work in sandboxed mode.", - ); - } - - // Admin pages/widgets require an `admin` route. - const hasAdminPages = (manifest.admin?.pages?.length ?? 0) > 0; - const hasAdminWidgets = (manifest.admin?.widgets?.length ?? 0) > 0; - if (hasAdminPages || hasAdminWidgets) { - const routeNames = manifest.routes.map((r) => (typeof r === "string" ? r : r.name)); - if (!routeNames.includes("admin")) { - const declared = - hasAdminPages && hasAdminWidgets - ? "adminPages and adminWidgets" - : hasAdminPages - ? "adminPages" - : "adminWidgets"; - validationErrors.push( - `Plugin declares ${declared} but the sandbox entry has no "admin" route. Add an admin route handler to serve Block Kit pages.`, - ); - } - } - - // Bundle size caps (RFC 0001 §"Bundle size limits"). - const bundleEntries = await collectBundleEntries(bundleDir); - const sizeViolations = validateBundleSize(bundleEntries); - if (sizeViolations.length > 0) { - validationErrors.push(...sizeViolations); - } else { - log.info?.( - `Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`, - ); - } - - if (validationErrors.length > 0) { - throw new BundleError( - "VALIDATION_FAILED", - `Bundle validation failed:\n - ${validationErrors.join("\n - ")}`, - ); - } - - log.success?.("Validation passed"); - - // ── 8. Create tarball (or stop here if validateOnly) ── - if (validateOnly) { - return { - manifest, - tarballPath: null, - tarballBytes: null, - sha256: null, - warnings, - }; - } - - await mkdir(outDir, { recursive: true }); - const tarballName = `${manifest.id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "")}-${manifest.version}.tar.gz`; - const tarballPath = join(outDir, tarballName); - - log.start?.("Creating tarball..."); - await createTarball(bundleDir, tarballPath); - - const tarballStat = await stat(tarballPath); - const tarballBuf = await readFile(tarballPath); - const sha256 = createHash("sha256").update(tarballBuf).digest("hex"); - - log.success?.(`Created ${tarballName} (${(tarballStat.size / 1024).toFixed(1)}KB)`); - log.info?.(` SHA-256: ${sha256}`); - log.info?.(` Path: ${tarballPath}`); - - return { - manifest, - tarballPath, - tarballBytes: tarballStat.size, - sha256, - warnings, - }; - } finally { - // Always clean up. mkdtemp produced this dir for us, so there's no - // chance of nuking something the user expected to keep. - await rm(tmpDir, { recursive: true, force: true }); - } -} - -// ────────────────────────────────────────────────────────────────────────── -// Helpers -// ────────────────────────────────────────────────────────────────────────── - -async function resolveEntries(pluginDir: string, log: BundleLogger): Promise { - const pkgPath = join(pluginDir, "package.json"); - if (!(await fileExists(pkgPath))) { - throw new BundleError("MISSING_PACKAGE_JSON", `No package.json found in ${pluginDir}`); - } - - const pkg = JSON.parse(await readFile(pkgPath, "utf-8")) as PackageJson; - - let backendEntry: string | undefined; - let adminEntry: string | undefined; - - if (pkg.exports) { - const sandboxExport = pkg.exports["./sandbox"]; - if (typeof sandboxExport === "string") { - backendEntry = await resolveSourceEntry(pluginDir, sandboxExport); - } else if ( - sandboxExport && - typeof sandboxExport === "object" && - "import" in sandboxExport && - typeof (sandboxExport as { import: unknown }).import === "string" - ) { - backendEntry = await resolveSourceEntry( - pluginDir, - (sandboxExport as { import: string }).import, - ); - } - - const adminExport = pkg.exports["./admin"]; - if (typeof adminExport === "string") { - adminEntry = await resolveSourceEntry(pluginDir, adminExport); - } else if ( - adminExport && - typeof adminExport === "object" && - "import" in adminExport && - typeof (adminExport as { import: unknown }).import === "string" - ) { - adminEntry = await resolveSourceEntry(pluginDir, (adminExport as { import: string }).import); - } - } - - if (!backendEntry) { - const defaultSandbox = join(pluginDir, "src/sandbox-entry.ts"); - if (await fileExists(defaultSandbox)) { - backendEntry = defaultSandbox; - } - } - - let mainEntry: string | undefined; - if (pkg.exports?.["."] !== undefined) { - const mainExport = pkg.exports["."]; - if (typeof mainExport === "string") { - mainEntry = await resolveSourceEntry(pluginDir, mainExport); - } else if ( - mainExport && - typeof mainExport === "object" && - "import" in mainExport && - typeof (mainExport as { import: unknown }).import === "string" - ) { - mainEntry = await resolveSourceEntry(pluginDir, (mainExport as { import: string }).import); - } - } - if (!mainEntry && pkg.main) { - mainEntry = await resolveSourceEntry(pluginDir, pkg.main); - } - if (!mainEntry) { - const defaultMain = join(pluginDir, "src/index.ts"); - if (await fileExists(defaultMain)) { - mainEntry = defaultMain; - } - } - - if (!mainEntry) { - throw new BundleError( - "MISSING_ENTRYPOINT", - "Cannot find plugin entrypoint. Expected src/index.ts or main/exports in package.json.", - ); - } - - log.info?.(`Main entry: ${mainEntry}`); - if (backendEntry) log.info?.(`Backend entry: ${backendEntry}`); - if (adminEntry) log.info?.(`Admin entry: ${adminEntry}`); - - return { mainEntry, backendEntry, adminEntry, pkg }; -} - -interface ExtractContext { - pluginDir: string; - tmpDir: string; - entries: ResolvedEntries; - build: typeof import("tsdown").build; -} - -async function extractResolvedPlugin(ctx: ExtractContext): Promise { - const { pluginDir, tmpDir, entries, build } = ctx; - const mainOutDir = join(tmpDir, "main"); - await build({ - config: false, - entry: [entries.mainEntry], - format: "esm", - outDir: mainOutDir, - dts: false, - platform: "node", - external: ["emdash", EMDASH_SCOPE_RE], - }); - - const pluginNodeModules = join(pluginDir, "node_modules"); - const tmpNodeModules = join(mainOutDir, "node_modules"); - if (await fileExists(pluginNodeModules)) { - await symlink(pluginNodeModules, tmpNodeModules, "junction"); - } - - const mainBaseName = basename(entries.mainEntry).replace(TS_EXT_RE, ""); - const mainOutputPath = await findBuildOutput(mainOutDir, mainBaseName); - if (!mainOutputPath) { - throw new BundleError( - "MAIN_BUILD_FAILED", - `Failed to build main entry — no output found in ${mainOutDir}`, - ); - } - - const pluginModule = (await import(mainOutputPath)) as Record; - - let resolvedPlugin: ResolvedPlugin | undefined; - let descriptor: Record | undefined; - - // Strict format detection. We only call exports we can identify by name -- - // `createPlugin()` (native) or the default export (standard descriptor or - // pre-resolved object). Speculatively calling every named export is a - // foot-gun: a `validateInput()` helper that returns `{id, version}` would - // be mis-resolved. - if (typeof pluginModule.createPlugin === "function") { - const native = (pluginModule.createPlugin as () => unknown)(); - if (!isResolvedPluginShape(native)) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "createPlugin() returned something that's not a ResolvedPlugin (missing id, version, or wrong types).", - ); - } - resolvedPlugin = native; - } else if (typeof pluginModule.default === "function") { - // Standard format default export. The factory returns a descriptor - // (id + version + serialisable fields, no hook handlers); we build - // the ResolvedPlugin shape around it and probe the sandbox entry for - // hook/route names below. - const result = (pluginModule.default as () => unknown)(); - if (!isPluginDescriptorShape(result)) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Default export factory returned something that's not a plugin descriptor (missing id or version, or wrong types).", - ); - } - descriptor = result; - resolvedPlugin = buildResolvedFromDescriptor(result); - } else if (typeof pluginModule.default === "object" && pluginModule.default !== null) { - const defaultExport = pluginModule.default; - if (!isResolvedPluginShape(defaultExport)) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Default export object is not a ResolvedPlugin (missing id, version, or wrong types).", - ); - } - resolvedPlugin = defaultExport; - } - - if (!resolvedPlugin) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Could not extract plugin definition. Expected one of:\n" + - " - `export function createPlugin() { ... }` (native format)\n" + - " - `export default function() { return { id, version, ... } }` (standard format)\n" + - " - `export default { id, version, ... }` (pre-resolved native)", - ); - } - - // For the standard format, probe the sandbox entry to capture hook and - // route names for the manifest. Only runs when we have a descriptor (i.e. - // the plugin came in via the standard format) and a sandbox entry. - if (descriptor && entries.backendEntry) { - await augmentWithSandboxProbe({ - resolvedPlugin, - descriptor, - backendEntry: entries.backendEntry, - tmpDir, - build, - }); - } - - // If a standard-format descriptor declares hooks/routes we couldn't probe - // (because there's no sandbox entry), the published manifest will be a - // lie -- the host will refuse to dispatch hooks the plugin promised to - // implement. Catch it here. - if ( - descriptor && - !entries.backendEntry && - (hasNonEmptyArrayField(descriptor, "hooks") || hasNonEmptyArrayField(descriptor, "routes")) - ) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Plugin descriptor declares hooks or routes but no sandbox entry exists to back them. " + - 'Add `src/sandbox-entry.ts` (or a "./sandbox" export in package.json) that ' + - "exports `default { hooks, routes }`. Without it, the published manifest " + - "will promise functionality the bundle can't deliver.", - ); - } - - return resolvedPlugin; -} - -function buildResolvedFromDescriptor(descriptor: Record): ResolvedPlugin { - return { - id: descriptor.id as string, - version: descriptor.version as string, - capabilities: (descriptor.capabilities as ResolvedPlugin["capabilities"]) ?? [], - allowedHosts: (descriptor.allowedHosts as string[]) ?? [], - storage: (descriptor.storage as ResolvedPlugin["storage"]) ?? {}, - hooks: {}, - routes: {}, - admin: { - pages: descriptor.adminPages as ResolvedPlugin["admin"]["pages"], - widgets: descriptor.adminWidgets as ResolvedPlugin["admin"]["widgets"], - }, - }; -} - -/** - * Type guard: does this value look like a `ResolvedPlugin` enough to use? - * - * Validates the fields we read in the bundling pipeline. Doesn't try to - * exhaustively validate hook/route handler shapes -- those are functions and - * the manifest only records their names. - */ -function isResolvedPluginShape(value: unknown): value is ResolvedPlugin { - if (!value || typeof value !== "object") return false; - const v = value as Record; - if (typeof v.id !== "string" || v.id.length === 0) return false; - if (typeof v.version !== "string" || v.version.length === 0) return false; - if (v.capabilities !== undefined && !Array.isArray(v.capabilities)) return false; - if (v.allowedHosts !== undefined && !Array.isArray(v.allowedHosts)) return false; - if (v.storage !== undefined && (typeof v.storage !== "object" || v.storage === null)) { - return false; - } - if (v.hooks !== undefined && (typeof v.hooks !== "object" || v.hooks === null)) { - return false; - } - if (v.routes !== undefined && (typeof v.routes !== "object" || v.routes === null)) { - return false; - } - if (v.admin !== undefined && (typeof v.admin !== "object" || v.admin === null)) { - return false; - } - return true; -} - -/** - * Type guard: does this value look like a plugin descriptor (the standard - * format's factory return value)? - * - * Looser than `isResolvedPluginShape` -- descriptors don't carry hooks or - * routes (those live in the sandbox entry, probed separately). - */ -function isPluginDescriptorShape(value: unknown): value is Record { - if (!value || typeof value !== "object") return false; - const v = value as Record; - if (typeof v.id !== "string" || v.id.length === 0) return false; - if (typeof v.version !== "string" || v.version.length === 0) return false; - if (v.capabilities !== undefined) { - if (!Array.isArray(v.capabilities)) return false; - // Reject non-string entries -- they'd serialize into a malformed - // manifest.json and confuse the runtime. - if (v.capabilities.some((c) => typeof c !== "string")) return false; - } - if (v.allowedHosts !== undefined) { - if (!Array.isArray(v.allowedHosts)) return false; - if (v.allowedHosts.some((h) => typeof h !== "string")) return false; - } - return true; -} - -/** - * `descriptor[field]` is a non-empty array (or non-empty object). Used to - * detect when a standard-format descriptor declares hooks/routes that the - * bundler can't populate (because there's no sandbox entry to probe). - */ -function hasNonEmptyArrayField(descriptor: Record, field: string): boolean { - const v = descriptor[field]; - if (Array.isArray(v)) return v.length > 0; - if (v && typeof v === "object") return Object.keys(v).length > 0; - return false; -} - -/** - * Write a stub `emdash.mjs` into `dir` that the user's plugin code resolves - * its `import "emdash"` against during build/probe. The shim's surface is: - * - * - `definePlugin` (named + default-property): identity function. The - * standard format's only legal `emdash` import. - * - default export: a Proxy. Any property access other than - * `definePlugin` returns a function that throws on call with a clear - * message. Without the Proxy, plugins doing dynamic property access on - * the default would silently get undefined and tree-shake to nothing. - * - * Named imports of anything other than `definePlugin` (e.g. - * `import { admin } from "emdash"`) are caught by the bundler at build - * time -- the named binding doesn't exist on the shim, so tsdown / Rollup - * errors with "Module 'emdash' has no exported member 'admin'". That's a - * better failure mode than a runtime undefined, so we don't try to handle - * unknown named imports at the shim level. - */ -async function writeEmdashShim(dir: string): Promise { - await mkdir(dir, { recursive: true }); - const path = join(dir, "emdash.mjs"); - const source = `export const definePlugin = (d) => d; -const NOT_AVAILABLE = (name) => () => { - throw new Error( - \`Sandboxed plugins must not import "\${name}" from "emdash". Only \\\`definePlugin\\\` is available in standard format.\` - ); -}; -const handler = { - get(target, prop, receiver) { - if (prop === "definePlugin") return target.definePlugin; - if (typeof prop !== "string") return Reflect.get(target, prop, receiver); - return NOT_AVAILABLE(prop); - }, -}; -export default new Proxy({ definePlugin }, handler); -`; - await writeFile(path, source); - return path; -} - -interface ProbeContext { - resolvedPlugin: ResolvedPlugin; - descriptor: Record; - backendEntry: string; - tmpDir: string; - build: typeof import("tsdown").build; -} - -async function augmentWithSandboxProbe(ctx: ProbeContext): Promise { - const { resolvedPlugin, descriptor, backendEntry, tmpDir, build } = ctx; - const backendProbeDir = join(tmpDir, "backend-probe"); - const probeShimPath = await writeEmdashShim(join(tmpDir, "probe-shims")); - await build({ - config: false, - entry: [backendEntry], - format: "esm", - outDir: backendProbeDir, - dts: false, - platform: "neutral", - external: [], - alias: { emdash: probeShimPath }, - treeshake: true, - }); - const backendBaseName = basename(backendEntry).replace(TS_EXT_RE, ""); - const backendProbePath = await findBuildOutput(backendProbeDir, backendBaseName); - if (!backendProbePath) return; - - const backendModule = (await import(backendProbePath)) as Record; - const standardDef = (backendModule.default ?? {}) as Record; - const hooks = standardDef.hooks as Record | undefined; - const routes = standardDef.routes as Record | undefined; - - if (hooks) { - for (const hookName of Object.keys(hooks)) { - const hookEntry = hooks[hookName]; - const handler = extractHookHandler(hookEntry); - if (!handler) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - `Sandbox entry's hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, - ); - } - const config: Record = - typeof hookEntry === "object" && hookEntry !== null - ? (hookEntry as Record) - : {}; - resolvedPlugin.hooks[hookName] = { - handler, - priority: (config.priority as number | undefined) ?? 100, - timeout: (config.timeout as number | undefined) ?? 5000, - dependencies: (config.dependencies as string[] | undefined) ?? [], - errorPolicy: (config.errorPolicy as string | undefined) ?? "abort", - exclusive: (config.exclusive as boolean | undefined) ?? false, - pluginId: descriptor.id as string, - }; - } - } - if (routes) { - for (const [name, route] of Object.entries(routes)) { - const handler = extractRouteHandler(route); - if (!handler) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - `Sandbox entry's route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, - ); - } - const routeObj: Record = - typeof route === "object" && route !== null ? (route as Record) : {}; - resolvedPlugin.routes[name] = { - handler, - public: routeObj.public as boolean | undefined, - }; - } - } -} - -/** - * Extract a hook handler from either the bare function form or the - * `{ handler, priority, ... }` config form. Returns `undefined` if neither - * shape is present so callers can hard-fail with a useful error. - */ -function extractHookHandler(entry: unknown): unknown { - if (typeof entry === "function") return entry; - if (entry && typeof entry === "object" && "handler" in entry) { - const handler = (entry as { handler: unknown }).handler; - if (typeof handler === "function") return handler; - } - return undefined; -} - -/** - * Same as `extractHookHandler` for route entries. - */ -function extractRouteHandler(entry: unknown): unknown { - if (typeof entry === "function") return entry; - if (entry && typeof entry === "object" && "handler" in entry) { - const handler = (entry as { handler: unknown }).handler; - if (typeof handler === "function") return handler; - } - return undefined; -} - -function describeShape(value: unknown): string { - if (value === null) return "null"; - if (value === undefined) return "undefined"; - if (Array.isArray(value)) return `array (length ${value.length})`; - return typeof value; -} - -interface CollectAssetsContext { - pluginDir: string; - bundleDir: string; - log: BundleLogger; - warn: (msg: string) => void; -} - -async function collectAssets(ctx: CollectAssetsContext): Promise { - const { pluginDir, bundleDir, log, warn } = ctx; - - const readmePath = join(pluginDir, "README.md"); - if (await fileExists(readmePath)) { - await copyFile(readmePath, join(bundleDir, "README.md")); - log.success?.("Included README.md"); - } - - const iconPath = join(pluginDir, "icon.png"); - if (await fileExists(iconPath)) { - const iconBuf = await readFile(iconPath); - const dims = readImageDimensions(iconBuf); - if (!dims) { - warn("icon.png is not a valid PNG — skipping"); - } else { - if (dims[0] !== ICON_SIZE || dims[1] !== ICON_SIZE) { - warn( - `icon.png is ${dims[0]}x${dims[1]}, expected ${ICON_SIZE}x${ICON_SIZE} — including anyway`, - ); - } - await copyFile(iconPath, join(bundleDir, "icon.png")); - log.success?.("Included icon.png"); - } - } - - const screenshotsDir = join(pluginDir, "screenshots"); - if (await fileExists(screenshotsDir)) { - const screenshotFiles = (await readdir(screenshotsDir)) - .filter((f) => { - const ext = extname(f).toLowerCase(); - return ext === ".png" || ext === ".jpg" || ext === ".jpeg"; - }) - .toSorted() - .slice(0, MAX_SCREENSHOTS); - - if (screenshotFiles.length > 0) { - await mkdir(join(bundleDir, "screenshots"), { recursive: true }); - for (const file of screenshotFiles) { - const filePath = join(screenshotsDir, file); - const buf = await readFile(filePath); - const dims = readImageDimensions(buf); - if (!dims) { - warn(`screenshots/${file} — cannot read dimensions, skipping`); - continue; - } - if (dims[0] > MAX_SCREENSHOT_WIDTH || dims[1] > MAX_SCREENSHOT_HEIGHT) { - warn( - `screenshots/${file} is ${dims[0]}x${dims[1]}, max ${MAX_SCREENSHOT_WIDTH}x${MAX_SCREENSHOT_HEIGHT} — including anyway`, - ); - } - await copyFile(filePath, join(bundleDir, "screenshots", file)); - } - log.success?.(`Included ${screenshotFiles.length} screenshot(s)`); - } - } -} diff --git a/packages/registry-cli/src/manifest/schema.ts b/packages/registry-cli/src/manifest/schema.ts deleted file mode 100644 index 3a5b5de1d..000000000 --- a/packages/registry-cli/src/manifest/schema.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Zod schema for `emdash-plugin.jsonc` — the publisher-authored manifest that - * sits next to a plugin's source and feeds the registry CLI's `publish`, - * `validate`, and `init` commands. - * - * Relationship to the lexicon - * --------------------------- - * - * This schema is NOT the lexicon. The lexicon - * (`com.emdashcms.experimental.package.profile`) is the on-wire atproto - * record format, optimised for content-addressed storage and aggregator - * indexing. This schema is the authoring format, optimised for a human - * editing a file in VS Code with `$schema`-powered IDE completion. - * - * Fields that exist in BOTH places use the lexicon's field names verbatim - * (`license`, `keywords`, `repo`, `name`, `description`). Fields that the - * publisher cannot reasonably write by hand are derived at publish time and - * do not appear here: `id` (full AT URI requires the publisher's DID), - * `type` (always `"emdash-plugin"` from this CLI), `slug` (derived from the - * bundled `manifest.json`'s `id`), `lastUpdated` (set at publish time), - * `artifacts.package` (filled in from the fetched tarball), `extensions` - * (computed from the bundled manifest's capabilities + allowedHosts). - * - * The translation step lives in `./translate.ts`. - * - * Single-vs-multi-author convenience - * ---------------------------------- - * - * The lexicon stores `authors` and `security` as arrays. The overwhelmingly - * common case is one author and one security contact, so the manifest - * accepts both shapes: - * - * // single-author - * { "author": { "name": "Jane Doe" }, "security": { "email": "..." } } - * - * // multi-author - * { "authors": [{ "name": "..." }, { "name": "..." }], - * "securityContacts": [{ "email": "..." }] } - * - * `loadManifest` normalises both forms to the array shape before passing to - * publish. You can't mix forms for the same field (e.g. `author` AND - * `authors`); the schema rejects that. - * - * Strict mode - * ----------- - * - * Unknown keys are rejected with `.strict()`. This catches typos like - * `"licens": "MIT"` rather than letting them silently fall through. The - * tradeoff is that adding a field requires a CLI release; we accept that - * cost for v1 and may revisit after one cycle of field-add (issue #1029). - */ - -import { isDid, isHandle } from "@atcute/lexicons/syntax"; -import { z } from "zod"; - -// ────────────────────────────────────────────────────────────────────────── -// Field-level schemas — exported so tests can target individual rules. -// -// Each field uses `.meta({ description })` so the descriptions flow into -// the generated JSON Schema and surface as inline hover hints in editors -// that support `$schema`-driven completion (VS Code, IntelliJ). -// ────────────────────────────────────────────────────────────────────────── - -/** - * SPDX license expression. The lexicon caps this at 256 chars. We don't - * validate the SPDX grammar here — the registry aggregator does that and - * gives clearer errors. We DO refuse the empty string and obvious garbage - * (whitespace-only) so the publish command can surface a useful message - * before any network round-trip. - */ -export const LicenseSchema = z - .string() - .min(1, 'license must be a non-empty SPDX expression (e.g. "MIT")') - .max(256, "license must be <= 256 characters (SPDX expressions are short)") - .refine((v) => v.trim().length > 0, "license cannot be whitespace-only") - .meta({ - title: "License", - description: - 'SPDX license expression (e.g. "MIT", "Apache-2.0", "MIT OR Apache-2.0"). Required on first publish; ignored on subsequent publishes (the existing profile wins).', - examples: ["MIT", "Apache-2.0", "MIT OR Apache-2.0"], - }); - -/** - * One author. Mirrors `profile.json#author`. The lexicon says authors - * "SHOULD specify at least one of url or email"; we don't enforce that - * here because anonymous-but-named authors are a legitimate (if - * discouraged) shape. The publish command surfaces it as a warning. - */ -export const AuthorSchema = z - .object({ - name: z - .string() - .min(1, "author.name cannot be empty") - .max(256, "author.name must be <= 256 characters") - .meta({ description: "Display name." }), - url: z - .string() - .url("author.url must be a valid URL") - .max(1024, "author.url must be <= 1024 characters") - .meta({ - description: "Author's homepage or profile URL. Either this or `email` is recommended.", - }) - .optional(), - email: z - .string() - .email("author.email must be a valid email") - .max(256, "author.email must be <= 256 characters") - .meta({ description: "Author's contact email. Either this or `url` is recommended." }) - .optional(), - }) - .strict() - .meta({ - title: "Author", - description: "A single author entry. Mirrors the lexicon's author shape.", - }); - -/** - * One security contact. Mirrors `profile.json#contact`. The lexicon - * mandates "at least one of url or email MUST be present"; Lexicon JSON - * can't express "required one-of", so we enforce it here. Without this - * check a publisher could write `{ "security": {} }` and the publish - * record would carry an empty contact (which aggregators reject anyway, - * but failing here is a better user experience). - */ -export const SecurityContactSchema = z - .object({ - url: z - .string() - .url("security.url must be a valid URL") - .max(1024, "security.url must be <= 1024 characters") - .meta({ - description: - "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required.", - }) - .optional(), - email: z - .string() - .email("security.email must be a valid email") - .max(256, "security.email must be <= 256 characters") - .meta({ - description: "Security contact email. Either this or `url` is required.", - }) - .optional(), - }) - .strict() - .refine( - (v) => v.url !== undefined || v.email !== undefined, - "security contact must have at least one of `url` or `email`", - ) - .meta({ - title: "Security contact", - description: "A single security contact. At least one of `url` or `email` must be present.", - }); - -/** - * Publisher identity, used to verify the active session matches the - * manifest's pinned publisher at publish time. Accepts a DID or a handle. - * - * Recommended form: DID (`did:plc:...`). DIDs are durable — they survive - * handle changes. Handles are friendlier to read but mutable: if the - * publisher's handle changes, the manifest needs an update. - * - * Omitted on first publish, the CLI writes the active session's DID - * back into the manifest automatically. Subsequent publishes verify - * against the pinned value. - * - * Validation is structural only here: DID syntax (`did:method:id`) or - * handle syntax (`name.tld`). The actual resolve-to-DID step happens at - * publish time via `@atcute/identity-resolver`. - */ -export const PublisherSchema = z - .string() - .refine( - (v) => isDid(v) || isHandle(v), - 'publisher must be an atproto DID (e.g. "did:plc:abc123") or handle (e.g. "example.com")', - ) - .meta({ - title: "Publisher", - description: - "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", - examples: ["did:plc:abc123def456", "example.com"], - }); - -/** Optional human-readable display name. Mirrors `profile.json#name`. */ -export const NameSchema = z - .string() - .min(1, "name cannot be empty when set") - .max(1024, "name must be <= 1024 characters") - .meta({ - title: "Display name", - description: - "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted.", - }); - -/** Short description. Mirrors `profile.json#description`. */ -export const DescriptionSchema = z - .string() - .min(1, "description cannot be empty when set") - .max(1024, "description must be <= 1024 characters") - .meta({ - title: "Description", - description: - "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists.", - }); - -/** Search keywords. Mirrors `profile.json#keywords`. */ -export const KeywordsSchema = z - .array( - z.string().min(1, "keyword cannot be empty").max(128, "each keyword must be <= 128 characters"), - ) - .max(5, "keywords array must have <= 5 entries (FAIR convention)") - .meta({ - title: "Keywords", - description: "Search keywords (<= 5 entries, FAIR convention).", - }); - -/** - * Source repository URL. Mirrors `release.json#repo`. The lexicon accepts - * either an HTTPS URL or an AT URI; v1 of the CLI accepts HTTPS only. - * AT-URI source repos can be added in a later issue without changing the - * field name. - * - * We use a regex `pattern` rather than `.refine` for the https-only rule - * so the constraint flows through to the generated JSON Schema. Editors - * doing client-side validation against the schema then surface the same - * error the CLI does. - */ -export const RepoSchema = z - .string() - .regex(/^https:\/\//, "repo must be an https:// URL (AT-URI source repos aren't supported yet)") - .url("repo must be a valid URL") - .max(1024, "repo must be <= 1024 characters") - .meta({ - title: "Source repository", - description: "HTTPS URL of the plugin's source repository. Surfaced in registry listings.", - examples: ["https://github.com/emdash-cms/plugin-gallery"], - }); - -// ────────────────────────────────────────────────────────────────────────── -// Top-level manifest -// ────────────────────────────────────────────────────────────────────────── - -/** - * The full v1 manifest. Unknown keys are rejected by `.strict()` so a - * typo'd field name produces an immediate error rather than passing - * through silently. The cost is that every later issue (#1029, #1030, ...) - * has to extend this schema, which is intentional: the manifest is a - * contract with users and we want changes to be deliberate. - * - * `$schema` is allowed because editors write it automatically for IDE - * completion. It is stripped before validation passes the value to the - * publish translation. - */ -export const ManifestSchema = z - .object({ - // `$schema` is for editor IDE support and the JSON Schema tooling - // chain. It carries no semantic meaning to publish; the loader - // strips it before handing the value off. - $schema: z - .string() - .meta({ - description: - "Path or URL to the JSON Schema describing this file. Editors use this for completion and validation.", - }) - .optional(), - - // Required on first publish, ignored on subsequent publishes (the - // existing profile wins). Same precedence rules as today's - // --license flag. - license: LicenseSchema, - - // Optional publisher pin. Omitted on first publish, the CLI - // writes the active session's DID back here automatically. - publisher: PublisherSchema.optional(), - - // Single-author form. Mutually exclusive with `authors`. - author: AuthorSchema.optional(), - // Multi-author form. Mutually exclusive with `author`. At least one - // entry is required when this field is used. - authors: z - .array(AuthorSchema) - .min(1, "authors[] must have at least one entry") - .max(32, "authors[] must have <= 32 entries (lexicon constraint)") - .meta({ - title: "Authors (multiple)", - description: - "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one.", - }) - .optional(), - - // Single-contact form. Mutually exclusive with `securityContacts`. - security: SecurityContactSchema.optional(), - // Multi-contact form. Mutually exclusive with `security`. - securityContacts: z - .array(SecurityContactSchema) - .min(1, "securityContacts[] must have at least one entry") - .max(8, "securityContacts[] must have <= 8 entries (lexicon constraint)") - .meta({ - title: "Security contacts (multiple)", - description: - "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one.", - }) - .optional(), - - // Optional profile fields. - name: NameSchema.optional(), - description: DescriptionSchema.optional(), - keywords: KeywordsSchema.optional(), - - // Optional release fields. - repo: RepoSchema.optional(), - }) - .strict() - .refine((v) => !(v.author !== undefined && v.authors !== undefined), { - message: - "manifest has both `author` and `authors`. Use one form: `author: { ... }` for a single author, or `authors: [...]` for multiple.", - path: ["authors"], - }) - .refine((v) => !(v.security !== undefined && v.securityContacts !== undefined), { - message: - "manifest has both `security` and `securityContacts`. Use one form: `security: { ... }` for a single contact, or `securityContacts: [...]` for multiple.", - path: ["securityContacts"], - }) - .refine((v) => v.author !== undefined || v.authors !== undefined, { - message: "manifest must specify either `author: { ... }` or `authors: [...]`", - path: ["author"], - }) - .refine((v) => v.security !== undefined || v.securityContacts !== undefined, { - message: "manifest must specify either `security: { ... }` or `securityContacts: [...]`", - path: ["security"], - }) - .meta({ - title: "EmDash plugin manifest", - description: - "Hand-authored manifest for publishing a plugin to the EmDash plugin registry. Lives next to the plugin's `package.json` as `emdash-plugin.jsonc`.", - }); - -/** - * Validated manifest shape. Note: this is the SHAPE AFTER the schema's - * `.refine()` rules have run, not the on-disk shape. The single-form - * convenience fields (`author`, `security`) are still present at this - * stage; normalisation to the array forms happens in `./translate.ts`. - */ -export type Manifest = z.infer; - -/** A single author entry, normalised. */ -export type ManifestAuthor = z.infer; - -/** A single security contact entry, normalised. */ -export type ManifestSecurityContact = z.infer; diff --git a/packages/registry-cli/src/manifest/translate.ts b/packages/registry-cli/src/manifest/translate.ts deleted file mode 100644 index 0ecb10ad5..000000000 --- a/packages/registry-cli/src/manifest/translate.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Translate a validated manifest into the existing publish-input shape. - * - * The single-author / single-security-contact convenience forms are - * normalised here: by the time this returns, the caller sees only the - * array shapes the lexicon uses. - */ - -import type { ProfileBootstrap } from "../publish/api.js"; -import type { Manifest, ManifestAuthor, ManifestSecurityContact } from "./schema.js"; - -/** - * Normalised "after the schema's single/multi convenience has been - * collapsed" view of a manifest. The CLI passes this to the publish - * pipeline rather than the raw `Manifest` so the rest of the code - * never has to think about `author` vs `authors`. - */ -export interface NormalisedManifest { - license: string; - /** - * Pinned publisher (DID or handle). Undefined when the manifest - * doesn't pin a publisher; the CLI writes the active session's DID - * back after first publish so this is undefined only on first - * publish or in CI flows where the user opted out via `--no-manifest`. - */ - publisher: string | undefined; - authors: ManifestAuthor[]; - securityContacts: ManifestSecurityContact[]; - name: string | undefined; - description: string | undefined; - keywords: string[] | undefined; - repo: string | undefined; -} - -/** - * Collapse the convenience forms (`author`, `security`) into the array - * forms (`authors`, `securityContacts`). - * - * The manifest schema's `.refine()` rules already guarantee that exactly - * one of each pair is set, so the runtime checks here are defensive — a - * caller that bypassed validation would still produce a coherent result. - */ -export function normaliseManifest(manifest: Manifest): NormalisedManifest { - const authors = manifest.authors ?? (manifest.author ? [manifest.author] : []); - const securityContacts = - manifest.securityContacts ?? (manifest.security ? [manifest.security] : []); - return { - license: manifest.license, - publisher: manifest.publisher, - authors, - securityContacts, - name: manifest.name, - description: manifest.description, - keywords: manifest.keywords, - repo: manifest.repo, - }; -} - -/** - * Convert a normalised manifest into the `ProfileBootstrap` shape that - * `publishRelease` consumes. For multi-author manifests, the first - * author wins (the publish lexicon supports an array, but - * `ProfileBootstrap` doesn't model that yet). - * - * `name`, `description`, `keywords`, and `repo` are accepted by the - * manifest schema but not wired through here. They land in publish in a - * follow-up issue alongside the broader profile-fields work. The fields - * are not silently lost — the manifest is the source of truth and we'll - * read them again when the publish API accepts them. - */ -export function manifestToProfileBootstrap(manifest: NormalisedManifest): ProfileBootstrap { - const author = manifest.authors[0]; - const security = manifest.securityContacts[0]; - - const profile: ProfileBootstrap = { license: manifest.license }; - if (author?.name !== undefined) profile.authorName = author.name; - if (author?.url !== undefined) profile.authorUrl = author.url; - if (author?.email !== undefined) profile.authorEmail = author.email; - if (security?.email !== undefined) profile.securityEmail = security.email; - if (security?.url !== undefined) profile.securityUrl = security.url; - return profile; -} diff --git a/packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts b/packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts deleted file mode 100644 index 80fb9d328..000000000 --- a/packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Test fixture: descriptor declares hooks but the package has no sandbox - * entry. The bundler should hard-fail at validation rather than emit a - * manifest that promises functionality the bundle can't deliver. - */ -export default function badPlugin() { - return { - id: "bad-plugin", - version: "0.1.0", - capabilities: ["content:read"], - allowedHosts: [], - storage: {}, - // We declare hooks here, but there's no `src/sandbox-entry.ts` and - // no `./sandbox` package export, so the bundler can't probe for - // these hook names. - hooks: ["content:beforeCreate"], - }; -} diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts b/packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts deleted file mode 100644 index d1fe84af8..000000000 --- a/packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Test fixture: descriptor-factory plugin (the "standard" format the bundler - * recognises without needing any actual `emdash` runtime). - * - * The bundler's manifest probe imports this module and calls the default - * export; the returned object's id+version make it a valid descriptor. - */ -export default function fixturePlugin() { - return { - id: "fixture-minimal", - version: "1.2.3", - capabilities: ["content:read"], - allowedHosts: ["api.example.com"], - storage: {}, - }; -} diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/src/sandbox-entry.ts b/packages/registry-cli/tests/fixtures/minimal-plugin/src/sandbox-entry.ts deleted file mode 100644 index 009fb28fb..000000000 --- a/packages/registry-cli/tests/fixtures/minimal-plugin/src/sandbox-entry.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Test fixture: minimal sandbox entry. Exports a default object with hooks - * and routes so the bundler's probe captures shape into the manifest. - * - * Uses `definePlugin` from "emdash" (which the bundler aliases to a Proxy - * shim) so the shim resolution path is actually exercised by the bundle - * tests; without this `import`, the shim could be silently broken and tests - * would still pass. - */ -// eslint-disable-next-line import/no-unresolved -- the bundler aliases this -import { definePlugin } from "emdash"; - -export default definePlugin({ - hooks: { - "content:beforeCreate": (input: unknown) => input, - }, - routes: { - admin: () => new Response("ok"), - }, -}); diff --git a/packages/registry-client/README.md b/packages/registry-client/README.md index 5b9e8b167..8bdbe41f7 100644 --- a/packages/registry-client/README.md +++ b/packages/registry-client/README.md @@ -20,13 +20,13 @@ Persists a publisher's atproto session between CLI invocations. Three implementa ### Publishing (`@emdash-cms/registry-client/publishing`) -Repo operations against the publisher's own PDS: `putRecord`, `uploadBlob`, `getRecord`, `listRecords`. Used by the CLI's `emdash-registry publish` flow. +Repo operations against the publisher's own PDS: `putRecord`, `uploadBlob`, `getRecord`, `listRecords`. Used by the CLI's `emdash-plugin publish` flow. The interactive OAuth flow lives in the CLI, not here. This module accepts a pre-built atproto fetch handler (typically from `@atcute/oauth-node-client`) and wraps it with operations scoped to atproto repo NSIDs. ### Discovery (`@emdash-cms/registry-client/discovery`) -Read-only XRPC client over an aggregator. No authentication. Used by the CLI (`emdash-registry search`, `emdash-registry info`) and the EmDash admin UI's install flow. +Read-only XRPC client over an aggregator. No authentication. Used by the CLI (`emdash-plugin search`, `emdash-plugin info`) and the EmDash admin UI's install flow. The `acceptLabelers` option threads the `atproto-accept-labelers` request header through every call so callers can configure which labellers' hard-takedown labels the aggregator should apply. diff --git a/packages/registry-client/src/credentials/file.ts b/packages/registry-client/src/credentials/file.ts index 93e63f70a..cb613f540 100644 --- a/packages/registry-client/src/credentials/file.ts +++ b/packages/registry-client/src/credentials/file.ts @@ -157,7 +157,7 @@ export class FileCredentialStore implements CredentialStore { // their CLI or remove the file manually. if (!Number.isInteger(parsed.version) || parsed.version < 1 || parsed.version > FILE_VERSION) { throw new Error( - `credential store at ${this.path} has version ${parsed.version}; this CLI understands versions 1..${FILE_VERSION}. Upgrade emdash-registry or remove the file manually.`, + `credential store at ${this.path} has version ${parsed.version}; this CLI understands versions 1..${FILE_VERSION}. Upgrade emdash-plugin or remove the file manually.`, ); } // Future: branch on parsed.version < FILE_VERSION for migrations. diff --git a/packages/registry-client/src/credentials/types.ts b/packages/registry-client/src/credentials/types.ts index 811c371d3..5d3cdd037 100644 --- a/packages/registry-client/src/credentials/types.ts +++ b/packages/registry-client/src/credentials/types.ts @@ -1,7 +1,7 @@ /** * Credential shapes shared between the credential store, the publishing * client, and the CLI. These describe what we persist between an interactive - * `emdash-registry login` and subsequent CLI invocations. + * `emdash-plugin login` and subsequent CLI invocations. * * The store itself is implementation-defined (filesystem on disk, in-memory * for tests, env-vars for CI). All implementations satisfy `CredentialStore`. diff --git a/packages/registry-client/src/index.ts b/packages/registry-client/src/index.ts index 9be695070..365afe71d 100644 --- a/packages/registry-client/src/index.ts +++ b/packages/registry-client/src/index.ts @@ -8,10 +8,10 @@ * env-vars (CI), in-memory (tests). * - **Publishing** (`./publishing`): repo operations against the publisher's * own PDS using a session built by `@atcute/oauth-node-client`. Used by - * the CLI's `emdash-registry publish` flow. + * the CLI's `emdash-plugin publish` flow. * - **Discovery** (`./discovery`): read-only XRPC client over an aggregator. - * No authentication. Used by both the CLI (`emdash-registry search` / - * `emdash-registry info`) and the EmDash admin UI's install flow. + * No authentication. Used by both the CLI (`emdash-plugin search` / + * `emdash-plugin info`) and the EmDash admin UI's install flow. * * The two halves are deliberately decoupled so consumers that only need * discovery (most notably the admin UI) don't have to pull in the publishing diff --git a/packages/registry-client/src/publishing/index.ts b/packages/registry-client/src/publishing/index.ts index c45f69ebe..e464b21f2 100644 --- a/packages/registry-client/src/publishing/index.ts +++ b/packages/registry-client/src/publishing/index.ts @@ -6,7 +6,7 @@ * what was just written. * * This module deliberately does NOT implement the interactive OAuth flow - * itself. Callers (the CLI in `@emdash-cms/registry-cli`) are responsible for: + * itself. Callers (the CLI in `@emdash-cms/plugin-cli`) are responsible for: * 1. Driving the OAuth dance (browser-redirect with device-flow fallback, * DPoP-bound tokens) via `@atcute/oauth-node-client`. * 2. Persisting the resulting session somewhere durable. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70028512b..4aed6da41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ catalogs: better-sqlite3: specifier: ^12.8.0 version: 12.8.0 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -388,6 +391,9 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms @@ -474,6 +480,9 @@ importers: '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-embeds': specifier: workspace:* version: link:../../packages/plugins/embeds @@ -600,6 +609,9 @@ importers: '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-color': specifier: workspace:* version: link:../../packages/plugins/color @@ -752,6 +764,9 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms @@ -792,6 +807,9 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms @@ -1620,6 +1638,82 @@ importers: specifier: 'catalog:' version: 4.90.0(@cloudflare/workers-types@4.20260305.1) + packages/plugin-cli: + dependencies: + '@atcute/client': + specifier: 'catalog:' + version: 4.2.1 + '@atcute/identity-resolver': + specifier: 'catalog:' + version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@6.0.3))(@atcute/lexicons@1.3.0)(typescript@6.0.3) + '@atcute/lexicons': + specifier: 'catalog:' + version: 1.3.0 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 + '@atcute/oauth-node-client': + specifier: 'catalog:' + version: 1.1.0 + '@clack/prompts': + specifier: ^1.4.0 + version: 1.4.0 + '@emdash-cms/plugin-types': + specifier: workspace:* + version: link:../plugin-types + '@emdash-cms/registry-client': + specifier: workspace:* + version: link:../registry-client + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../registry-lexicons + '@oslojs/crypto': + specifier: 'catalog:' + version: 1.0.1 + chokidar: + specifier: 'catalog:' + version: 5.0.0 + citty: + specifier: ^0.1.6 + version: 0.1.6 + consola: + specifier: ^3.4.2 + version: 3.4.2 + image-size: + specifier: ^2.0.2 + version: 2.0.2 + jsonc-parser: + specifier: 'catalog:' + version: 3.3.1 + modern-tar: + specifier: ^0.7.5 + version: 0.7.6 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) + zod: + specifier: 'catalog:' + version: 4.4.1 + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + publint: + specifier: 'catalog:' + version: 0.3.17 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) + packages/plugin-types: devDependencies: '@arethetypeswrong/cli': @@ -1681,9 +1775,18 @@ importers: specifier: workspace:>=0.10.0 version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli + jsonc-parser: + specifier: 'catalog:' + version: 3.3.1 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.0-beta) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 vitest: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) @@ -1694,6 +1797,9 @@ importers: specifier: workspace:>=0.10.0 version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli tsdown: specifier: 'catalog:' version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) @@ -1797,6 +1903,9 @@ importers: specifier: workspace:* version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli tsdown: specifier: 'catalog:' version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) @@ -1810,6 +1919,9 @@ importers: specifier: workspace:* version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli tsdown: specifier: 'catalog:' version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) @@ -1823,82 +1935,15 @@ importers: specifier: workspace:>=0.10.0 version: link:../../core devDependencies: - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) - typescript: - specifier: 'catalog:' - version: 6.0.3 - - packages/registry-cli: - dependencies: - '@atcute/client': - specifier: 'catalog:' - version: 4.2.1 - '@atcute/identity-resolver': - specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@6.0.3))(@atcute/lexicons@1.3.0)(typescript@6.0.3) - '@atcute/lexicons': - specifier: 'catalog:' - version: 1.3.0 - '@atcute/multibase': - specifier: 'catalog:' - version: 1.2.0 - '@atcute/oauth-node-client': - specifier: 'catalog:' - version: 1.1.0 - '@emdash-cms/plugin-types': - specifier: workspace:* - version: link:../plugin-types - '@emdash-cms/registry-client': + '@emdash-cms/plugin-cli': specifier: workspace:* - version: link:../registry-client - '@emdash-cms/registry-lexicons': - specifier: workspace:* - version: link:../registry-lexicons - '@oslojs/crypto': - specifier: 'catalog:' - version: 1.0.1 - citty: - specifier: ^0.1.6 - version: 0.1.6 - consola: - specifier: ^3.4.2 - version: 3.4.2 - image-size: - specifier: ^2.0.2 - version: 2.0.2 - jsonc-parser: - specifier: 'catalog:' - version: 3.3.1 - modern-tar: - specifier: ^0.7.5 - version: 0.7.6 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 + version: link:../../plugin-cli tsdown: specifier: 'catalog:' version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) - zod: - specifier: 'catalog:' - version: 4.4.1 - devDependencies: - '@arethetypeswrong/cli': - specifier: 'catalog:' - version: 0.18.2 - '@types/node': - specifier: 'catalog:' - version: 24.10.13 - publint: - specifier: 'catalog:' - version: 0.3.17 typescript: specifier: 'catalog:' version: 6.0.3 - vitest: - specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) packages/registry-client: dependencies: @@ -2947,12 +2992,20 @@ packages: '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + '@clack/prompts@0.10.1': resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kumo@1.16.0': resolution: {integrity: sha512-uCrj7jGPvdXj8lrdQBfMGKzV3JTDi7hUBsLf4jpirD7QHvZMsGe6XuU+KKvQFqDTmj5ELXQVES4YVoducxZ7Tg==} hasBin: true @@ -7259,9 +7312,18 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -11767,6 +11829,11 @@ snapshots: dependencies: sisteransi: 1.0.5 + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@clack/prompts@0.10.1': dependencies: '@clack/core': 0.4.2 @@ -11778,6 +11845,13 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@cloudflare/kumo@1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -15117,7 +15191,7 @@ snapshots: '@astrojs/markdown-remark': 7.0.0-beta.11 '@astrojs/telemetry': 3.3.0 '@capsizecss/unpack': 4.0.0 - '@clack/prompts': 1.1.0 + '@clack/prompts': 1.4.0 '@oslojs/encoding': 1.1.0 '@rollup/pluginutils': 5.3.0(rollup@4.55.2) aria-query: 5.3.2 @@ -16483,8 +16557,18 @@ snapshots: fast-redact@3.5.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -18932,24 +19016,6 @@ snapshots: reusify@1.0.4: {} - rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@6.0.0-beta): - dependencies: - '@babel/generator': 8.0.0-rc.1 - '@babel/helper-validator-identifier': 8.0.0-rc.1 - '@babel/parser': 8.0.0-rc.1 - '@babel/types': 8.0.0-rc.1 - ast-kit: 3.0.0-beta.1 - birpc: 4.0.0 - dts-resolver: 2.1.3(oxc-resolver@11.16.4) - get-tsconfig: 4.13.6 - obug: 2.1.1 - rolldown: 1.0.0-rc.3 - optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260421.2 - typescript: 6.0.0-beta - transitivePeerDependencies: - - oxc-resolver - rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.1 @@ -19538,35 +19604,6 @@ snapshots: optionalDependencies: typescript: 6.0.3 - tsdown@0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.0-beta): - dependencies: - ansis: 4.2.0 - cac: 6.7.14 - defu: 6.1.4 - empathic: 2.0.0 - hookable: 6.0.1 - import-without-cache: 0.2.5 - obug: 2.1.1 - picomatch: 4.0.4 - rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@6.0.0-beta) - semver: 7.7.4 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - unconfig-core: 7.4.2 - unrun: 0.2.28 - optionalDependencies: - '@arethetypeswrong/core': 0.18.2 - publint: 0.3.17 - typescript: 6.0.0-beta - transitivePeerDependencies: - - '@ts-macro/tsc' - - '@typescript/native-preview' - - oxc-resolver - - synckit - - vue-tsc - tsdown@0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3): dependencies: ansis: 4.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f5abca8f..f90ab8288 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -118,10 +118,11 @@ catalog: "@types/node": 24.10.13 "@types/react": 19.2.14 "@types/react-dom": 19.2.3 - jsonc-parser: ^3.3.1 astro: ^6.0.1 astro-iconset: ^0.0.4 better-sqlite3: ^12.8.0 + chokidar: ^5.0.0 + jsonc-parser: ^3.3.1 publint: 0.3.17 react: 19.2.4 react-dom: 19.2.4 @@ -130,9 +131,4 @@ catalog: vite: ^8.0.11 vitest: ^4.1.5 wrangler: ^4.83.0 - # Catalog-pin Zod so the whole workspace dedupes on a single instance. - # Zod 4 embeds the version in the type, so even ^4.3.6 vs ^4.4.1 produce - # structurally incompatible ZodType across packages that mix astro/zod - # and emdash's zod (e.g. trusted plugins like @emdash-cms/plugin-forms - # importing 'z' from astro/zod and passing schemas to definePlugin). zod: ^4.4.1 diff --git a/templates/blank/.agents/skills/building-emdash-site/references/configuration.md b/templates/blank/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/blank/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/blank/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/blog-cloudflare/astro.config.mjs b/templates/blog-cloudflare/astro.config.mjs index 664fbbe25..1b87b1d75 100644 --- a/templates/blog-cloudflare/astro.config.mjs +++ b/templates/blog-cloudflare/astro.config.mjs @@ -2,7 +2,7 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; @@ -19,7 +19,7 @@ export default defineConfig({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [formsPlugin()], - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], sandboxRunner: sandbox(), marketplace: "https://marketplace.emdashcms.com", }), diff --git a/templates/blog/.agents/skills/building-emdash-site/references/configuration.md b/templates/blog/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/blog/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/blog/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/blog/astro.config.mjs b/templates/blog/astro.config.mjs index 662168127..726b23cf5 100644 --- a/templates/blog/astro.config.mjs +++ b/templates/blog/astro.config.mjs @@ -1,6 +1,6 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; import { defineConfig, fontProviders } from "astro/config"; import emdash, { local } from "emdash/astro"; import { sqlite } from "emdash/db"; @@ -22,7 +22,7 @@ export default defineConfig({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ], fonts: [ diff --git a/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md b/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md b/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/starter/.agents/skills/building-emdash-site/references/configuration.md b/templates/starter/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/starter/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/starter/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ```