From 09c6ce9db4ccd65b312f97b8ed43df48e4322532 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 16 May 2026 11:43:56 +0100 Subject: [PATCH 1/2] docs(plugins): document the plugin CLI, manifest, and authoring-shape changes (#1040, #1057) Adds migration guides for site operators and plugin authors, new reference pages for emdash-plugin.jsonc and the emdash-plugin CLI, and rewrites the sandboxed-plugin guides to the new default-export shape. Migration guides follow Astro's breaking-changes format; Atmosphere account terminology used throughout. --- docs/astro.config.mjs | 13 ++ .../plugins/creating-plugins/api-routes.mdx | 41 ++-- .../plugins/creating-plugins/block-kit.mdx | 11 +- .../plugins/creating-plugins/capabilities.mdx | 60 ++--- .../creating-plugins/choosing-a-format.mdx | 6 +- .../docs/plugins/creating-plugins/cli.mdx | 118 ++++++++++ .../docs/plugins/creating-plugins/hooks.mdx | 6 +- .../plugins/creating-plugins/manifest.mdx | 192 +++++++++++++++ .../creating-plugins/migrating-to-the-cli.mdx | 208 +++++++++++++++++ .../plugins/creating-plugins/publishing.mdx | 220 ++++++++---------- .../plugins/creating-plugins/settings.mdx | 23 +- .../docs/plugins/creating-plugins/storage.mdx | 124 +++++----- .../creating-plugins/your-first-plugin.mdx | 164 ++++++------- .../content/docs/plugins/upgrading-sites.mdx | 92 ++++++++ 14 files changed, 926 insertions(+), 352 deletions(-) create mode 100644 docs/src/content/docs/plugins/creating-plugins/cli.mdx create mode 100644 docs/src/content/docs/plugins/creating-plugins/manifest.mdx create mode 100644 docs/src/content/docs/plugins/creating-plugins/migrating-to-the-cli.mdx create mode 100644 docs/src/content/docs/plugins/upgrading-sites.mdx diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index a7f0157d7..e53eeea51 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -88,6 +88,7 @@ export default defineConfig({ items: [ { label: "Plugin Overview", slug: "plugins/overview" }, { label: "Installing Plugins", slug: "plugins/installing" }, + { label: "Upgrading Plugins", slug: "plugins/upgrading-sites" }, { label: "Field Kit", slug: "plugins/field-kit" }, ], }, @@ -102,6 +103,14 @@ export default defineConfig({ label: "Your First Plugin", slug: "plugins/creating-plugins/your-first-plugin", }, + { + label: "The Manifest", + slug: "plugins/creating-plugins/manifest", + }, + { + label: "The Plugin CLI", + slug: "plugins/creating-plugins/cli", + }, { label: "Hooks", slug: "plugins/creating-plugins/hooks" }, { label: "API Routes", slug: "plugins/creating-plugins/api-routes" }, { label: "Storage", slug: "plugins/creating-plugins/storage" }, @@ -115,6 +124,10 @@ export default defineConfig({ label: "Bundling & Publishing", slug: "plugins/creating-plugins/publishing", }, + { + label: "Migrating to the CLI", + slug: "plugins/creating-plugins/migrating-to-the-cli", + }, ], }, { diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index faf9328cb..ffc684273 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -7,21 +7,20 @@ import { Aside, Tabs, TabItem } from "@astrojs/starlight/components"; Plugins can expose API routes for their admin UI and external integrations. Routes are mounted under `/_emdash/api/plugins//` and run inside the sandbox runtime with the same `PluginContext` that hooks receive. -This page covers sandboxed (standard-format) plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in [Native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) for details. +This page covers sandboxed plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in [Native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) for details. ## Defining routes -Declare routes in `definePlugin()` from your `sandbox-entry.ts`: +Declare routes in the default export of `src/plugin.ts`: -```typescript title="src/sandbox-entry.ts" -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; import { z } from "astro/zod"; -export default definePlugin({ +export default { routes: { status: { - handler: async (_routeCtx, ctx: PluginContext) => { + handler: async (_routeCtx, ctx) => { return { ok: true, plugin: ctx.plugin.id }; }, }, @@ -32,7 +31,7 @@ export default definePlugin({ limit: z.number().default(50), cursor: z.string().optional(), }), - handler: async (routeCtx, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const { formId, limit, cursor } = routeCtx.input; const result = await ctx.storage.submissions.query({ @@ -46,10 +45,10 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; ``` -Standard-format route handlers take **two arguments**: `(routeCtx, ctx)`. +`satisfies SandboxedPlugin` infers `routeCtx` and `ctx` — no parameter annotations needed. Sandboxed route handlers take **two arguments**: `(routeCtx, ctx)`. - `routeCtx` carries request-shaped data: `{ input, request, requestMeta }`. - `ctx` is the same `PluginContext` you get inside hooks — `ctx.storage`, `ctx.kv`, `ctx.content`, `ctx.http`, `ctx.log`, etc. @@ -66,7 +65,7 @@ Routes mount at `/_emdash/api/plugins//`. Route names can | `analytics` | `events/recent` | `/_emdash/api/plugins/analytics/events/recent` | ## Authentication and CSRF @@ -191,17 +190,17 @@ routes: { ## Accessing the request -The full `Request` object is available as `routeCtx.request` for headers, raw body access, and URL parsing. `routeCtx.requestMeta` carries IP, user agent, and geo data normalised across platforms. +`routeCtx.request` is a **`SandboxedRequest`**: a portable `{ url, method, headers }` record that behaves identically in-process and inside an isolate. `headers` is a `Record` keyed by lowercased header name — index it by the lowercased name, or iterate with `Object.entries`. `url` is a string, so `new URL(request.url)` parses query params. `routeCtx.requestMeta` carries IP, user agent, and geo data normalised across platforms when available. ```typescript handler: async (routeCtx, ctx) => { const { request, requestMeta } = routeCtx; - const auth = request.headers.get("Authorization"); + const auth = request.headers["authorization"]; // lowercased key, no .get() const url = new URL(request.url); const page = url.searchParams.get("page"); - ctx.log.info("Request", { ip: requestMeta.ip, ua: requestMeta.userAgent }); + ctx.log.info("Request", { meta: requestMeta }); if (request.method !== "POST") { throw new Response("POST required", { status: 405 }); @@ -348,12 +347,18 @@ curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \ ## Route context reference ```typescript -// What standard-format handlers receive as their two arguments +// What sandboxed route handlers receive as their two arguments -interface StandardRouteContext { +interface SandboxedRequest { + url: string; + method: string; + headers: Record; // lowercased keys +} + +interface SandboxedRouteContext { input: TInput; - request: Request; - requestMeta: { ip: string | null; userAgent: string | null; geo?: GeoData }; + request: SandboxedRequest; + requestMeta?: unknown; } interface PluginContext { diff --git a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx index dd03f112e..79ffd1a0b 100644 --- a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx @@ -25,8 +25,7 @@ EmDash's Block Kit lets sandboxed plugins describe their admin UI as JSON. The h 6. The plugin returns new blocks, and the cycle repeats. ```typescript -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { SandboxedPlugin } from "emdash/plugin"; interface BlockInteraction { type: "page_load" | "block_action" | "form_submit"; @@ -35,10 +34,10 @@ interface BlockInteraction { values?: Record; } -export default definePlugin({ +export default { routes: { admin: { - handler: async (routeCtx, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as BlockInteraction; if (interaction.type === "page_load") { @@ -70,10 +69,10 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; ``` -The standard-format route handler takes two arguments: `routeCtx` (with `input`, `request`, `requestMeta`) and `ctx` (the `PluginContext`). +The route handler takes two arguments: `routeCtx` (with `input`, `request`, `requestMeta`) and `ctx` (the `PluginContext`). `satisfies SandboxedPlugin` infers both. ## Block types diff --git a/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx b/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx index 9b416bc81..ee92528ae 100644 --- a/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx @@ -5,25 +5,21 @@ description: Declare what your plugin needs, and understand what the sandbox enf import { Aside, Steps } from "@astrojs/starlight/components"; -Sandboxed plugins are isolated by default. To do anything beyond reading and writing their own KV and storage, a plugin has to **declare a capability** on its descriptor. The sandbox bridge gates every host-provided API based on those declarations — a plugin that didn't declare `content:read` doesn't get a `ctx.content`, and one that didn't declare `network:request` doesn't get `ctx.http`. +Sandboxed plugins are isolated by default. To do anything beyond reading and writing their own KV and storage, a plugin has to **declare a capability** in its [manifest](/plugins/creating-plugins/manifest/). The sandbox bridge gates every host-provided API based on those declarations — a plugin that didn't declare `content:read` doesn't get a `ctx.content`, and one that didn't declare `network:request` doesn't get `ctx.http`. This page covers what each capability grants, how the sandbox enforces them, and what's not enforceable. ## Declaring capabilities -Capabilities live on the descriptor (the file imported by `astro.config.mjs`), alongside `id`, `version`, and `entrypoint`: +Capabilities live in `emdash-plugin.jsonc`, alongside `slug` and the rest of the trust contract: -```typescript title="src/index.ts" -export function helloPlugin(): PluginDescriptor { - return { - id: "plugin-hello", - version: "0.1.0", - format: "standard", - entrypoint: "@my-org/plugin-hello/sandbox", +```jsonc title="emdash-plugin.jsonc" +{ + "slug": "plugin-hello", + // ...identity + profile... - capabilities: ["content:read", "network:request"], - allowedHosts: ["api.example.com"], - }; + "capabilities": ["content:read", "network:request"], + "allowedHosts": ["api.example.com"] } ``` @@ -51,35 +47,16 @@ A few things worth knowing: - **`network:request:unrestricted` exists for user-configured URLs.** A webhook plugin where the operator types in the destination URL needs to reach hosts that aren't in the manifest. Plugins that always call known APIs should use `network:request` + `allowedHosts`. - **`email:send` is gated by configuration, not just the capability.** A plugin can declare `email:send`, but `ctx.email` will only be populated if some other plugin has registered an `email:deliver` transport. - - ## Network host allowlists Plugins with `network:request` can only fetch hosts listed in `allowedHosts`. Wildcards are supported for subdomains: -```typescript -capabilities: ["network:request"], -allowedHosts: [ - "api.example.com", // exact host - "*.cdn.example.com", // any subdomain of cdn.example.com -], +```jsonc title="emdash-plugin.jsonc" +"capabilities": ["network:request"], +"allowedHosts": [ + "api.example.com", // exact host + "*.cdn.example.com" // any subdomain of cdn.example.com +] ``` The bridge checks the request URL's host against the allowlist before forwarding the request. A request to a host that wasn't declared throws inside the plugin without ever leaving the sandbox. @@ -94,7 +71,7 @@ When a sandbox runner is active, the runtime enforces: 1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there. -2. **Storage and KV scoping.** Every storage and KV operation is scoped to the plugin's id. A plugin can't read another plugin's KV or its storage collections, and it can only access storage collections it declared on the descriptor. +2. **Storage and KV scoping.** Every storage and KV operation is scoped to the plugin's slug. A plugin can't read another plugin's KV or its storage collections, and it can only access storage collections it declared in the manifest. 3. **Network isolation.** Direct `fetch()` and other network primitives are blocked by the runner. The only way to reach the network is `ctx.http.fetch()`, which goes through the bridge's host validation. @@ -120,11 +97,10 @@ This is why declaring extra capabilities matters even if you "might use them lat ## Bundle-time validation -`emdash plugin bundle` and `emdash plugin publish` perform additional checks: +`emdash-plugin bundle` and `emdash-plugin publish` perform additional checks: -- Every declared capability must be in the known set (typos fail the build). -- A plugin that declares `network:request` without populating `allowedHosts` triggers a warning — declare hosts or switch to `network:request:unrestricted` and document why. -- Deprecated capability names trigger warnings during `bundle`/`validate` and a hard fail on `publish`. +- Every declared capability must be in the recognised set (typos fail the build). +- `network:request` requires a non-empty `allowedHosts`; `network:request:unrestricted` requires it to be empty. See [the manifest reference](/plugins/creating-plugins/manifest/#capabilities). - The bundled `backend.js` can't import Node.js built-ins (`fs`, `path`, `child_process`, etc.) — sandbox runtimes don't provide them. See [Bundling and publishing](/plugins/creating-plugins/publishing/) for the full list of checks. diff --git a/docs/src/content/docs/plugins/creating-plugins/choosing-a-format.mdx b/docs/src/content/docs/plugins/creating-plugins/choosing-a-format.mdx index bded26bed..63650089a 100644 --- a/docs/src/content/docs/plugins/creating-plugins/choosing-a-format.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/choosing-a-format.mdx @@ -15,7 +15,7 @@ Choose native only when you need a feature the sandbox can't provide. | | Sandboxed | Native | | ----------------------------------- | -------------------------------------------------- | ------------------------------------- | -| `format` field on descriptor | `"standard"` | `"native"` | +| Authoring shape | `emdash-plugin.jsonc` + `src/plugin.ts` | `definePlugin()` descriptor | | Install method | One-click from the admin marketplace | `npm install` + edit `astro.config` | | Runs in | An isolated runtime provided by a sandbox runner | Same process as your Astro site | | Capabilities | Enforced by the sandbox bridge | Enforced in-process by the same bridge | @@ -45,7 +45,7 @@ If your plugin can do its job in the sandbox, it should. There are three reasons to choose native, and they're all about features that need build-time integration with the host site: 1. **Custom React admin pages or widgets.** Sandboxed plugins describe their admin UI with Block Kit — a JSON schema that the admin renders on the plugin's behalf. If you need full React (custom hooks, third-party components, complex state), you need native. -2. **Astro components for rendering Portable Text blocks on the public site.** A plugin can declare a custom block type with `format: "standard"`, but the Astro components that render it on the public site have to be loaded at build time from npm. Only native plugins can provide a `componentsEntry`. +2. **Astro components for rendering Portable Text blocks on the public site.** A sandboxed plugin can declare a custom block type, but the Astro components that render it on the public site have to be loaded at build time from npm. Only native plugins can provide a `componentsEntry`. 3. **Injecting raw HTML, scripts, or stylesheets into public pages.** The `page:fragments` hook ships first-party code to visitors' browsers — outside any sandbox boundary. It's restricted to native plugins. Sandboxed plugins can still contribute to public pages through the `page:metadata` hook, which covers a lot of real use cases: - `meta` tags (`name` + `content`) — SEO descriptions, robots directives, Twitter cards @@ -71,7 +71,7 @@ The runner most sites use today is `sandbox()` from `@emdash-cms/cloudflare`, wh If no runner is configured, or if the configured runner reports as unavailable on the current platform, plugins listed under `sandboxed: []` are skipped at startup with a debug-level log. -If you want a standard-format plugin to run on a platform without a sandbox runner, move it from `sandboxed: []` into the `plugins: []` array — it'll execute in-process. Capability declarations are still honoured (the same `PluginContext` factory gates `ctx.content`, `ctx.http`, and friends), but there is no isolation boundary, no resource limits, and a buggy or malicious plugin can call `fetch()` directly, read environment variables, or block the event loop. Without a sandbox runner active, treat every plugin as a native plugin for trust purposes. +If you want a sandboxed plugin to run on a platform without a sandbox runner, move it from `sandboxed: []` into the `plugins: []` array — it'll execute in-process. Capability declarations are still honoured (the same `PluginContext` factory gates `ctx.content`, `ctx.http`, and friends), but there is no isolation boundary, no resource limits, and a buggy or malicious plugin can call `fetch()` directly, read environment variables, or block the event loop. Without a sandbox runner active, treat every plugin as a native plugin for trust purposes. ## Next diff --git a/docs/src/content/docs/plugins/creating-plugins/cli.mdx b/docs/src/content/docs/plugins/creating-plugins/cli.mdx new file mode 100644 index 000000000..0d187422a --- /dev/null +++ b/docs/src/content/docs/plugins/creating-plugins/cli.mdx @@ -0,0 +1,118 @@ +--- +title: The emdash-plugin CLI +description: init, build, dev, bundle, validate, publish — the plugin authoring toolchain. +--- + +import { Steps } from "@astrojs/starlight/components"; + +`@emdash-cms/plugin-cli` is the authoring toolchain: scaffold, build, watch, validate, bundle, publish, plus identity and discovery. The binary is `emdash-plugin`. + +## Commands + +The CLI provides the following commands: + +```text +emdash-plugin init [name] Scaffold a new sandboxed plugin +emdash-plugin build Build dist/ (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 validate [path] Validate emdash-plugin.jsonc against the schema +emdash-plugin publish --url Publish a release pointing at a hosted tarball +emdash-plugin login Sign in with your Atmosphere account +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 registry search +emdash-plugin info Show package details +``` + +All commands accept `--json`. Discovery commands (`search`, `info`) accept `--aggregator ` (or `EMDASH_REGISTRY_URL`). + +The following example shows the two scripts most plugins add to `package.json`: + +```json title="package.json" +{ + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" + } +} +``` + +## `init` + +Create a new plugin with `init`: + +```sh +npx @emdash-cms/plugin-cli init my-plugin +``` + +This scaffolds a self-contained plugin: `emdash-plugin.jsonc`, `src/plugin.ts` (one example route in the `satisfies SandboxedPlugin` shape), `package.json`, `tsconfig.json`, a test, a README, and `.gitignore`. A scaffold created from just a slug is a valid starting point. The manifest carries `TODO:` comments for the few fields you must fill in — publisher, author, and security contact — before the plugin will load or publish. `init` never requires extra flags to succeed. + +## `build` + +`build` reads `emdash-plugin.jsonc`, `src/plugin.ts`, and an optional sibling `package.json`, and emits the following files: + +| Artifact | What it is | +| ----------------------------------------- | ------------------------------------------------------------------------------------------- | +| `dist/plugin.mjs` (+ `dist/plugin.d.mts`) | The hooks and routes. Loaded in-process (`plugins: []`) and by the sandbox loader (`sandboxed: []`). | +| `dist/manifest.json` | The plugin's manifest, including the hooks and routes read from `src/plugin.ts`. `bundle` includes this file as-is; npm consumers read it without parsing the JSONC source. | +| `dist/index.mjs` (+ `dist/index.d.mts`) | The descriptor module a site imports in `astro.config.mjs`. Emitted only when a sibling `package.json` exists; registry-only plugins skip it, since nothing imports it. | + +`dist/` is build output. Do not commit it. The scaffold's `.gitignore` excludes it, and installs rebuild it. + +## `dev` + +Watches `src/**`, `emdash-plugin.jsonc`, and `package.json`, debouncing rebuilds at 150 ms. Rebuilds are serialised. On a **failed** rebuild it leaves the last good `dist/` in place, so a site importing the plugin via a workspace/file link keeps working until the next successful build. Ctrl-C drains cleanly. + +Develop against a real site by running `pnpm dev` here and `pnpm add file:../path/to/this` in the site, then importing the plugin's default export into `emdash({ sandboxed: [...] })`. + +## `validate` + +```sh +emdash-plugin validate # ./emdash-plugin.jsonc +emdash-plugin validate path/ # a specific directory +``` + +Offline schema check with `tsc`-style `file:line:column` diagnostics, including the manifest's cross-field rules. No network. Good as a pre-commit or CI gate. See [the manifest reference](/plugins/creating-plugins/manifest/). + +## `bundle` + +`bundle` is a thin packaging step on top of `build`: + + + +1. Runs `build` to produce `dist/`. +2. Validates the bundle: no Node-builtin imports, no oversized files, capability sanity. +3. Collects optional assets — README, icon, screenshots. +4. Tarballs. Inside the tarball, `plugin.mjs` is packed as `backend.js` to match the registry's wire-side filename. The output is `dist/-.tar.gz`. + + + +`--validate-only` skips tarball creation but still produces the `dist/` artifacts — "validate" implies "build first". + +## `publish` + +The CLI does not host artifacts; you do, anywhere public. + +```sh +emdash-plugin login # if not already logged in +emdash-plugin bundle # produces dist/-.tar.gz +# upload that tarball to a public URL, then: +emdash-plugin publish --url https://your-host/my-plugin-1.0.0.tar.gz +``` + +`publish` reads the manifest for profile fields and enforces [publisher pinning](/plugins/creating-plugins/manifest/#publisher-pinning). On first publish, pass `--license` and a security contact (or keep them in the manifest). Explicit flags override manifest values, which is useful in CI; `--no-manifest` opts out entirely. + +Full walkthrough: [Bundling and publishing](/plugins/creating-plugins/publishing/). + +## 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 credential helpers, import from `@emdash-cms/registry-client`. diff --git a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx index 4e59e32c7..a18f788f0 100644 --- a/docs/src/content/docs/plugins/creating-plugins/hooks.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/hooks.mdx @@ -7,19 +7,21 @@ import { Aside, Tabs, TabItem } from "@astrojs/starlight/components"; Hooks let plugins run code in response to events. All hooks receive an event object and the plugin context, and they're declared at plugin definition time — there's no dynamic registration at runtime. -This page covers sandboxed (standard-format) plugins. Hooks work identically in native plugins; the only difference is that native plugins can also register `page:fragments`, which sandboxed plugins can't. +This page covers sandboxed plugins. Hooks work identically in native plugins; native plugins can additionally register `page:fragments`. ## Hook signature Every hook handler takes two arguments: ```typescript -async (event: EventType, ctx: PluginContext) => ReturnType; +async (event, ctx) => ReturnType; ``` - `event` — data about what just happened (content being saved, media uploaded, lifecycle transition, etc.) - `ctx` — the [`PluginContext`](/plugins/creating-plugins/your-first-plugin/) with storage, KV, logging, and capability-gated APIs +`satisfies SandboxedPlugin` on the default export infers `event` from the hook name (the full canonical event type) and `ctx` as `PluginContext`, so handlers need no parameter annotations. To reference an event type by name in a helper, import it from `emdash/plugin`. + ## Hook configuration A hook can be declared as a bare handler or wrapped in a config object: diff --git a/docs/src/content/docs/plugins/creating-plugins/manifest.mdx b/docs/src/content/docs/plugins/creating-plugins/manifest.mdx new file mode 100644 index 000000000..d0c198539 --- /dev/null +++ b/docs/src/content/docs/plugins/creating-plugins/manifest.mdx @@ -0,0 +1,192 @@ +--- +title: The plugin manifest +description: Reference for emdash-plugin.jsonc — identity, trust contract, profile fields, and publisher pinning. +--- + +import { Aside } from "@astrojs/starlight/components"; + +Every sandboxed plugin has an `emdash-plugin.jsonc` next to its `package.json`. It is hand-edited and holds the plugin's identity, its trust contract (capabilities, hosts, storage), and the profile fields the registry shows. `emdash-plugin init` scaffolds one; the CLI reads `./emdash-plugin.jsonc` automatically for `build`, `dev`, `validate`, `bundle`, and `publish`. + +The file is **JSONC**: comments and trailing commas are allowed. + +The following example shows a complete manifest for an image-gallery plugin: + +```jsonc title="emdash-plugin.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 profile + "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": {} +} +``` + + + +## Identity + +| Field | Required | Notes | +| ----------- | -------- | ---------------------------------------------------------------------------------------------- | +| `slug` | Yes | URL-safe id within the publisher's namespace. `/^[a-z][a-z0-9_-]*$/`, max 64 chars. | +| `publisher` | Yes | Your [Atmosphere account](/plugins/creating-plugins/publishing/#your-atmosphere-account)'s DID or handle. See [Publisher pinning](#publisher-pinning). | +| `version` | No | Semver 2.0 without build-metadata. Usually omit it — see below. | + +`slug` and `publisher` together are the package's identity. The registry derives the package's full identifier from them; you never write it by hand. + +### `version` lives in `package.json` + +The build reconciles the manifest's `version` against `package.json#version`: + +- Both set and equal → fine. +- Both set and different → hard error. +- One set → that value wins. +- Neither set → hard error. + +The recommended pattern for an npm-distributed plugin is to **omit `version` from the manifest** and let `package.json` be the single source of truth (your release tooling already bumps it there). Registry-only plugins with no `package.json` must set `version` in the manifest — there is nowhere else for it to live. + +## Profile + +These feed the registry listing. `license` and a security contact are required; the rest are optional. + +| Field | Required | Notes | +| -------------------------- | -------- | ------------------------------------------------------------------ | +| `license` | Yes | SPDX expression (`"MIT"`, `"Apache-2.0"`, `"MIT OR Apache-2.0"`). Used on first publish; the existing profile wins on later publishes. | +| `author` / `authors` | Yes | One of the two. `author: { name, url?, email? }` for a single author; `authors: [...]` (≤ 32) for several. Setting both is an error. | +| `security` / `securityContacts` | Yes | One of the two. Each contact needs at least one of `email` or `url`. `securityContacts: [...]` (≤ 8) for several. Setting both is an error. | +| `name` | No | Display name. Defaults to the slug. | +| `description` | No | Keep it short (around 140 characters). Long values may be truncated in lists. | +| `keywords` | No | ≤ 5 entries. | +| `repo` | No | `https://` URL of the source repo. | + +Use the singular `author` / `security` form unless you genuinely have multiple — it is the common case and the scaffold emits it. + +## Trust contract + +The trust contract is `capabilities`, `allowedHosts`, and `storage`. All three default to empty, so a plugin that needs no extra privileges can omit them entirely. + +```jsonc +{ + "capabilities": ["network:request", "content:read"], + "allowedHosts": ["api.example.com", "*.cdn.example.com"], + "storage": { + "events": { "indexes": ["timestamp"] }, + "submissions": { "indexes": ["email"], "uniqueIndexes": ["token"] } + } +} +``` + + + +### Capabilities + +The recognised names: + +| Capability | Grants | +| ----------------------------------- | --------------------------------------------------- | +| `content:read` / `content:write` | Read / mutate site content via `ctx`. | +| `media:read` / `media:write` | Read / write media. | +| `users:read` | Read user records. | +| `email:send` | Send email via `ctx`. | +| `network:request` | Outbound HTTP via `ctx.http`, restricted to `allowedHosts`. | +| `network:request:unrestricted` | Outbound HTTP to any host. Used instead of `network:request`. | +| `hooks.email-transport:register` | Register an email transport hook. | +| `hooks.email-events:register` | Register email lifecycle hooks. | +| `hooks.page-fragments:register` | Register a `page:fragments` hook (native only). | + +Two cross-field rules the CLI enforces (the editor's JSON-Schema check does not — run `emdash-plugin validate`): + +- `network:request` **requires** a non-empty `allowedHosts`. If the plugin really must reach any host, use `network:request:unrestricted` instead. +- `network:request:unrestricted` **requires** `allowedHosts` to be empty — the unrestricted capability already grants every host, so a list would contradict it. + +Host patterns are bare hostnames (no scheme, path, or whitespace). A leading `*.` allows subdomains: `*.cdn.example.com`. + +### Storage + +A map of collection name → index config. Collection names follow the same `/^[a-z][a-z0-9_]*$/` rule (the runtime uses the name as a SQL table suffix). Indexes are field names or composite arrays; `uniqueIndexes` are queryable too — don't also list them in `indexes`. + +```jsonc +"storage": { + "events": { "indexes": ["timestamp", ["collection", "timestamp"]] } +} +``` + +## Admin surface + +Optional. Sandboxed plugins render admin pages and dashboard widgets through [Block Kit](/plugins/creating-plugins/block-kit/); the manifest only declares where they appear. Omit the `admin` key entirely if the plugin has no admin UI. + +```jsonc +"admin": { + "pages": [{ "path": "/gallery", "label": "Gallery", "icon": "image" }], + "widgets": [{ "id": "recent-uploads", "title": "Recent uploads", "size": "half" }] +} +``` + +A plugin that declares `admin.pages` or `admin.widgets` must also serve an `admin` route in `src/plugin.ts` that renders the Block Kit content — the schema can't enforce that (route names are probed from source, not the manifest), but the runtime checks it. + +## Publisher pinning + +`publisher` pins the publishing identity so you can't accidentally publish a plugin under the wrong account. + +On your **first successful publish**, if the manifest's `publisher` matches the active session, it stays as written. If you scaffolded with `emdash-plugin init` and left it blank, the CLI writes the active session's DID back into the manifest. + +The following example shows the line the CLI writes, with the resolved handle added as a comment for readability: + +```jsonc title="emdash-plugin.jsonc" +"publisher": "did:plc:abc123def456", // jane.example.com +``` + +On **every subsequent publish**, the CLI resolves the active session and the pinned `publisher` to DIDs and compares them. A mismatch fails immediately with `MANIFEST_PUBLISHER_MISMATCH` — there is no override flag. Resolve it deliberately: + +- Wrong session: `emdash-plugin switch `, then publish again. +- Genuinely transferring the plugin to a new publisher: edit `publisher` in the manifest. + + + +## Validate without publishing + +```sh +emdash-plugin validate # ./emdash-plugin.jsonc +emdash-plugin validate path/ # a specific directory +``` + +Offline schema check with `tsc`-style `file:line:column` diagnostics, including the cross-field rules. Suitable for a pre-commit hook or CI step. Duplicate keys and unknown keys are errors (strict mode catches `"licens"` typos). + +## CLI flags still win + +Explicit flags (`--license`, `--author-name`, …) override manifest values when both are set — useful for CI overrides. `--no-manifest` skips the manifest entirely (and warns if one exists at the default path, so the publisher-pin safety story stays visible). + +## Next + +- [The `emdash-plugin` CLI](/plugins/creating-plugins/cli/) +- [Bundling and publishing](/plugins/creating-plugins/publishing/) +- [Capabilities and security](/plugins/creating-plugins/capabilities/) diff --git a/docs/src/content/docs/plugins/creating-plugins/migrating-to-the-cli.mdx b/docs/src/content/docs/plugins/creating-plugins/migrating-to-the-cli.mdx new file mode 100644 index 000000000..2b626fa78 --- /dev/null +++ b/docs/src/content/docs/plugins/creating-plugins/migrating-to-the-cli.mdx @@ -0,0 +1,208 @@ +--- +title: Migrating to the plugin CLI +description: Breaking changes for sandboxed-plugin authors, and how to update your plugin. +--- + +import { Aside } from "@astrojs/starlight/components"; + +This guide is for authors of **sandboxed** plugins written against the previous `definePlugin()` shape. Work through the breaking changes in order. None of them change how your hooks or routes behave at runtime; they change how the plugin is declared, built, and published. + + + +For the full list of changes in each package, see the [EmDash changelog](https://github.com/emdash-cms/emdash/blob/main/CHANGELOG.md). + +## Breaking changes + +### Renamed: `@emdash-cms/registry-cli` is now `@emdash-cms/plugin-cli` + +Earlier releases shipped the CLI as `@emdash-cms/registry-cli`, with an `emdash-registry` binary. + +The package is now `@emdash-cms/plugin-cli` and the binary is `emdash-plugin`. The old package is no longer published. + +#### What should I do? + +Replace the dependency: + +```sh +pnpm remove @emdash-cms/registry-cli +pnpm add -D @emdash-cms/plugin-cli +``` + +Replace `emdash-registry` with `emdash-plugin` everywhere you call it. Every subcommand keeps its name (`bundle`, `publish`, `login`, `whoami`, `switch`, `validate`), and `init`, `build`, and `dev` are added. See [The plugin CLI](/plugins/creating-plugins/cli/). + +### Changed: sandboxed plugins are defined with `satisfies SandboxedPlugin` + +Earlier releases wrapped the plugin's hooks and routes in `definePlugin()` imported from `emdash`, with each handler's parameters annotated by hand. + +A sandboxed plugin is now a bare default export annotated with `satisfies SandboxedPlugin`. The type comes from `emdash/plugin`, a type-only entry point that the bundler erases. TypeScript infers each handler's `event` and `ctx` from the hook or route name, so handler parameters need no annotations. + +#### What should I do? + +Make four changes to the plugin's source file. Replace the import: + +```ts del={1} ins={2} +import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash"; +import type { SandboxedPlugin } from "emdash/plugin"; +``` + +Replace the `definePlugin()` wrapper with a bare object and a `satisfies` annotation: + +```ts del={1} ins={2} "} satisfies SandboxedPlugin;" +export default definePlugin({ /* hooks, routes */ }); +export default { /* hooks, routes */ } satisfies SandboxedPlugin; +``` + +Remove the parameter annotations from every handler: + +```ts del={1} ins={2} +handler: async (event: ContentHookEvent, ctx: PluginContext) => { +handler: async (event, ctx) => { +``` + +The result is one default-exported object: + +```ts title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; + +export default { + hooks: { + "content:beforeSave": { + handler: async (event, ctx) => { + return event.content; + }, + }, + }, +} satisfies SandboxedPlugin; +``` + +To name an event type in a helper function, import it from `emdash/plugin`: + +```ts +import type { ContentHookEvent, PluginContext } from "emdash/plugin"; +``` + +A handler's `event` is always the canonical type for that hook. Annotating a handler with a narrower interface no longer type-checks. Validate any fields you depend on at runtime with a `typeof` check or a guard, which is the correct approach for data that comes from outside the type system. + +### Changed: a plugin is one `src/plugin.ts` plus `emdash-plugin.jsonc` + +Earlier releases split a plugin into two files: `src/index.ts` returned a `PluginDescriptor` (id, version, capabilities, storage, entrypoint), and `src/sandbox-entry.ts` held the hooks and routes. + +A plugin is now one runtime file, `src/plugin.ts` (hooks and routes), and one hand-edited manifest, `emdash-plugin.jsonc` (identity and the trust contract). The `entrypoint` and `format` fields are gone; the build wires them up. + +#### What should I do? + +Move the hooks and routes into `src/plugin.ts` using the shape above. Move the descriptor's metadata into `emdash-plugin.jsonc` next to `package.json`. The descriptor `id` becomes the manifest `slug`; `capabilities`, `allowedHosts`, and `storage` keep their shape; `version` is read from `package.json`, so omit it. + +The following example shows the manifest equivalent of a descriptor that declared one storage collection: + +```jsonc title="emdash-plugin.jsonc" +{ + "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "plugin-hello", + "publisher": "did:plc:abc123def456", + + "license": "MIT", + "author": { "name": "Jane Doe", "url": "https://example.com" }, + "security": { "email": "security@example.com" }, + + "capabilities": [], + "allowedHosts": [], + "storage": { "events": { "indexes": ["timestamp"] } } +} +``` + +See [The plugin manifest](/plugins/creating-plugins/manifest/) for every field, and [Publisher pinning](/plugins/creating-plugins/manifest/#publisher-pinning) for the `publisher` field. + +In `package.json`, point the `"./sandbox"` export at the built runtime file: + +```json del={1} ins={2} +"./sandbox": "./dist/sandbox-entry.mjs" +"./sandbox": "./dist/plugin.mjs" +``` + +Add the manifest to `files` so it ships with the package: + +```json del={1} ins={2} +"files": ["dist"] +"files": ["dist", "emdash-plugin.jsonc"] +``` + +### Changed: build with `emdash-plugin build` + +Earlier releases built the two source files with a hand-written `tsdown` script. + +`emdash-plugin build` reads `emdash-plugin.jsonc` and `src/plugin.ts` and emits the `dist/` artifacts. `emdash-plugin dev` watches and rebuilds. + +#### What should I do? + +Replace the build script and add a watch script: + +```json title="package.json" del={2} ins={3,4} +"scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean" + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" +} +``` + +Then validate and build: + +```sh +emdash-plugin validate +emdash-plugin build +``` + +### Removed: standard-format type and function exports from `emdash` + +Earlier releases exported `StandardPluginDefinition`, `StandardHookHandler`, `StandardHookEntry`, `StandardRouteHandler`, `StandardRouteEntry`, and the function `isStandardPluginDefinition` from `emdash`. + +These are removed. They were helper aliases for the previous `definePlugin` shape. + +#### What should I do? + +Use `SandboxedPlugin` from `emdash/plugin` for the same purpose. A sandboxed plugin's default export is already typed by its `satisfies SandboxedPlugin` annotation, so there is no replacement for `isStandardPluginDefinition`; identify a plugin by its structure (`{ hooks?, routes? }`) if you need to. + +### Renamed: the runtime `SandboxedPlugin` type is now `SandboxedPluginInstance` + +This affects only authors of a custom `SandboxRunner`, such as `@emdash-cms/cloudflare`. Most plugin authors can skip it. + +`SandboxedPlugin` from `emdash` now refers to the author-facing source shape. The runtime handle returned by `SandboxRunner.load` is `SandboxedPluginInstance`. + +#### What should I do? + +If you import `SandboxedPlugin` from `emdash` to type a sandbox runner or hold runtime plugin handles, change the import to `SandboxedPluginInstance`: + +```ts del={1} ins={2} +import type { SandboxedPlugin } from "emdash"; +import type { SandboxedPluginInstance } from "emdash"; +``` + +## Tell your users + +Sites that install your plugin also need to change their import. Point them at the new shape: drop the braces and the `()`. + +```js title="astro.config.mjs" del={1,7} ins={2,8} +import { helloPlugin } from "@my-org/plugin-hello"; +import hello from "@my-org/plugin-hello"; + +export default defineConfig({ + integrations: [ + emdash({ + sandboxed: [helloPlugin()], + sandboxed: [hello], + }), + ], +}); +``` + +If your plugin accepted configuration through its factory, that configuration moves to the admin UI's plugin settings. Read it at runtime through `ctx.kv` or settings instead. See [Settings](/plugins/creating-plugins/settings/). + +## Next + +- [The plugin manifest](/plugins/creating-plugins/manifest/) +- [The plugin CLI](/plugins/creating-plugins/cli/) +- [Your first sandboxed plugin](/plugins/creating-plugins/your-first-plugin/) diff --git a/docs/src/content/docs/plugins/creating-plugins/publishing.mdx b/docs/src/content/docs/plugins/creating-plugins/publishing.mdx index e86137902..e4bd36c3f 100644 --- a/docs/src/content/docs/plugins/creating-plugins/publishing.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/publishing.mdx @@ -1,184 +1,146 @@ --- title: Bundling and publishing -description: Bundle your sandboxed plugin and publish it to the EmDash marketplace. +description: Bundle a sandboxed plugin and publish it to the EmDash plugin registry. --- -import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components"; +import { Aside, Steps } from "@astrojs/starlight/components"; -Once your sandboxed plugin works, you can publish it to the EmDash marketplace so other sites can install it from the admin dashboard with one click. The publishing flow is sandboxed-only — native plugins distribute via npm and aren't bundled into marketplace tarballs. +Once your sandboxed plugin works, publish it so other sites can install it. Publishing is sandboxed-only — native plugins distribute via npm. -## Prerequisites +When you publish, the CLI records the release to your own [Atmosphere account](#your-atmosphere-account). The registry never stores your plugin's code. **You host the tarball** yourself — a GitHub release asset, R2, S3, or any public URL — and the registry stores a link to it. -Before publishing, make sure your plugin: + -- Has a `package.json` with both a `"."` export (the descriptor) and a `"./sandbox"` export (the runtime entry). -- Uses `format: "standard"` on the descriptor. -- Has a unique `id` and a valid semver `version`. -- Declares its `capabilities` and `allowedHosts` accurately on the descriptor. +## Prerequisites -## Bundle format +- A valid [`emdash-plugin.jsonc`](/plugins/creating-plugins/manifest/) with `slug`, `publisher`, `license`, and a security contact. Run `emdash-plugin validate` to confirm. +- A `version` (in `package.json`, or the manifest for registry-only plugins). +- An [Atmosphere account](#your-atmosphere-account) to publish under. -Published plugins are distributed as `.tar.gz` tarballs containing: +## Your Atmosphere account -| File | Required | Description | -| ---------------- | -------- | ------------------------------------------------------------------ | -| `manifest.json` | Yes | Plugin metadata extracted from the descriptor and sandbox entry | -| `backend.js` | Yes | Bundled sandbox code (self-contained ES module) | -| `admin.js` | No | Bundled admin UI code (only if Block Kit interactions ship JS) | -| `README.md` | No | Plugin documentation | -| `icon.png` | No | Plugin icon (256×256 PNG) | -| `screenshots/` | No | Up to 5 screenshots (PNG/JPEG, max 1920×1080) | +You publish under an **[Atmosphere account](https://atmosphereaccount.com)**: a portable, user-owned identity used across [Bluesky](https://bsky.app) and other apps in the [AT Protocol](https://atproto.com/) network. One account is your single login across the network, with the same `@handle` everywhere, and your identity and data are not tied to any one app. EmDash uses this account as your publisher identity: every release you publish is a record in your own account, signed in as you. -`manifest.json` is generated automatically. It contains the plugin id, version, capabilities, allowed hosts, hook names, route names, and admin configuration — but no executable code. +EmDash uses the same Atmosphere accounts as its [Atmosphere login](/guides/atmosphere-auth/) for sites. -## Building a bundle +### Use an existing account -```bash -cd packages/plugins/my-plugin -emdash plugin bundle -``` +If you already have a Bluesky account or any other Atmosphere account, sign in with its handle: -This will: +```sh +emdash-plugin login alice.bsky.social +``` - -1. Read your `package.json` to find entrypoints. -2. Build the descriptor entry to extract id, version, capabilities, and admin config. -3. Bundle `backend.js` from the `"./sandbox"` export — minified, tree-shaken, fully self-contained. -4. Bundle `admin.js` if a `"./admin"` export exists. -5. Collect assets (README, icon, screenshots). -6. Validate the bundle (size limits, no Node.js built-ins in `backend.js`, capability checks). -7. Write `{id}-{version}.tar.gz` to `dist/`. - +This opens your account provider's sign-in page in the browser. EmDash never sees your password. `emdash-plugin whoami` lists your stored sessions; `emdash-plugin switch ` changes the active one. -### Entrypoint resolution +### Sign up for an account -The bundle command finds your code through `package.json` exports: +If you do not have an Atmosphere account yet, create one through any provider, then run `emdash-plugin login `. Your options: -```json title="package.json" -{ - "exports": { - ".": { "import": "./dist/index.mjs" }, - "./sandbox": { "import": "./dist/sandbox-entry.mjs" }, - "./admin": { "import": "./dist/admin.mjs" } - } -} -``` +- **An app, such as [Bluesky](https://bsky.app).** Signing up for Bluesky creates an Atmosphere account hosted by Bluesky. This is the quickest route. +- **An independent provider.** Community-run or privacy-focused account hosts. Browse options at [atmosphereaccount.com](https://atmosphereaccount.com). +- **Self-hosted.** Run your own provider for full control over your identity and data. -| Export | Purpose | Built as | -| ------------- | ---------------------------------------------------------- | ------------------------------------- | -| `"."` | Descriptor — used to extract the manifest | Externals: `emdash`, `@emdash-cms/*` | -| `"./sandbox"` | Runtime code (`hooks`, `routes`) executed in the sandbox | Fully self-contained (no externals) | -| `"./admin"` | Admin UI components (only if you ship them) | Fully self-contained | +Whichever you choose, the `@handle` from that account is what you pass to `emdash-plugin login`, and the account's DID is what you pin as the [`publisher`](/plugins/creating-plugins/manifest/#publisher-pinning) in your manifest. -If `"./sandbox"` is missing, the command looks for `src/sandbox-entry.ts` as a fallback. The bundler maps dist paths back to source automatically — if your `"."` export points to `./dist/index.mjs`, it will find and build `src/index.ts`. +## Three steps -### Options +The following commands log in, build a tarball, and publish a release that points at the hosted tarball: -```bash -emdash plugin bundle [--dir ] [--outDir ] +```sh +emdash-plugin login # if not already logged in +emdash-plugin bundle # produces dist/-.tar.gz +# upload that tarball to a public URL, then: +emdash-plugin publish --url https://your-host/-.tar.gz ``` -| Flag | Default | Description | -| ---------------- | ----------------- | --------------------------------- | -| `--dir` | Current directory | Plugin source directory | -| `--outDir`, `-o` | `dist` | Output directory for the tarball | - -### Validation +`bundle` prints the next two steps when it finishes, including the `--url` invocation, so you don't have to remember the shape. -The bundle command checks: +## Bundle -- **Size limit** — total bundle must be under 5 MB. -- **No Node.js built-ins in `backend.js`** — sandbox code can't import `fs`, `path`, `child_process`, etc. Replace them with Web APIs or move the logic to a native plugin. -- **Capability allowlist** — declared capabilities must be in the known set (typos fail). -- **Deprecated capability names** trigger warnings here and a hard fail at publish time. -- **`network:request` without `allowedHosts`** triggers a warning (consider `network:request:unrestricted` if hosts are operator-configured at runtime, or list the hosts explicitly). -- **Icon dimensions** — `icon.png` should be 256×256 (warns if wrong; still includes it). -- **Screenshot limits** — max 5 screenshots, max 1920×1080. +`bundle` runs [`build`](/plugins/creating-plugins/cli/#build), validates, collects assets, and creates a tarball. Inside the tarball, `plugin.mjs` is packed as `backend.js` (the filename the registry expects). -## Publishing +The command accepts the following flags: -```bash -emdash plugin publish +```sh +emdash-plugin bundle [--dir ] [--outDir|-o ] [--validateOnly] ``` -This finds the most recent `.tar.gz` in `dist/` and uploads it. To be explicit about the tarball or to build before publishing: +| Flag | Default | Description | +| ---------------- | ----------------- | ------------------------------------------------------ | +| `--dir` | Current directory | Plugin source directory. | +| `--outDir`, `-o` | `dist` | Output directory for the tarball. | +| `--validateOnly` | `false` | Skip the tarball, but still produce `dist/` artifacts. | -```bash -# Explicit tarball path -emdash plugin publish --tarball dist/my-plugin-1.0.0.tar.gz +### Tarball contents -# Build first, then publish -emdash plugin publish --build -``` +| File | Required | Description | +| --------------- | -------- | ------------------------------------------------------------ | +| `manifest.json` | Yes | Generated manifest: id, version, capabilities, hosts, and the hooks and routes read from your source. You do not maintain this by hand. | +| `backend.js` | Yes | The built, self-contained runtime file (`dist/plugin.mjs`). | +| `README.md` | No | Plugin documentation. | +| `icon.png` | No | 256×256 PNG. | +| `screenshots/` | No | Up to 5, max 1920×1080. | -### Authentication +### Validation -The first time you publish, the CLI authenticates you via GitHub: +`bundle` (and `--validateOnly`) check: - -1. The CLI opens GitHub's device authorization page in your browser. -2. You enter the code displayed in your terminal. -3. GitHub issues an access token. -4. The CLI exchanges it for a marketplace JWT (stored in `~/.config/emdash/auth.json`). - - -The token lasts 30 days. After it expires you'll be prompted to re-authenticate on the next publish. +- **Size caps (RFC 0001, decompressed):** total ≤ 256 KB, per-file ≤ 128 KB, ≤ 20 files. The gzipped tarball is a fraction of that. +- **No Node built-ins in `backend.js`** — sandbox code can't import `fs`, `path`, `child_process`, etc. Use Web APIs, or move that logic to a native plugin. +- **Capability sanity** — names must be in the recognised set. +- **Trust-contract coherence** — the `network:request` / `allowedHosts` cross-rules from [the manifest](/plugins/creating-plugins/manifest/#capabilities). +- **Asset limits** — icon 256×256, ≤ 5 screenshots at ≤ 1920×1080. -You can manage authentication separately: +To inspect the tarball before publishing, list its contents: -```bash -emdash plugin login # log in without publishing -emdash plugin logout # clear stored token +```sh +emdash-plugin bundle +tar tzf dist/my-plugin-1.1.0.tar.gz ``` -### First-time registration +## Publish -If your plugin id isn't yet known to the marketplace, `emdash plugin publish` registers it automatically before uploading the first version. +Publishing writes the release record. The tarball must already be hosted at a public URL: -### Version requirements +```sh +emdash-plugin publish --url +``` -Each published version must have a higher semver than the last. You can't overwrite or republish an existing version — bump the version in both `package.json` and the descriptor before publishing again. +`--url` is required: it is where the plugin's bytes live, and the registry record points at it. To verify the hosted URL serves the exact bytes you built before writing the record, pass `--local`: -### Security audit +```sh +emdash-plugin publish --url https://your-host/foo-1.0.0.tar.gz --local dist/foo-1.0.0.tar.gz +``` -Every published version goes through an automated security audit. The audit scans `backend.js` and `admin.js` for: +What `publish` does: -- Data exfiltration patterns -- Credential harvesting via settings -- Obfuscated code -- Resource abuse (crypto mining, etc.) -- Suspicious network activity + -The audit produces a verdict of **pass**, **warn**, or **fail**, displayed on the plugin's marketplace listing. Depending on the marketplace's enforcement level, a **fail** verdict may block publication entirely. +1. Fetches the tarball at `--url` (with URL and size guards) and extracts the manifest from those bytes. +2. Resumes your Atmosphere account session and checks [publisher pinning](/plugins/creating-plugins/manifest/#publisher-pinning) — the active session must match the manifest's pinned `publisher`, or it refuses with `MANIFEST_PUBLISHER_MISMATCH`. +3. Creates the package profile from the manifest on first publish (`license`, author, security contact). On later publishes, the existing profile wins. +4. Publishes the profile and release records to your account. -### Options + -```bash -emdash plugin publish [--tarball ] [--build] [--dir ] [--registry ] -``` +On first publish you can supply profile fields by flag (`--license`, `--security-email`, …) instead of the manifest; explicit flags override manifest values, which is handy in CI. `--no-manifest` opts out of the manifest entirely. -| Flag | Default | Description | -| ------------ | -------------------------------------- | ----------------------------------------------- | -| `--tarball` | Latest `.tar.gz` in `dist/` | Explicit tarball path | -| `--build` | `false` | Run `emdash plugin bundle` before publishing | -| `--dir` | Current directory | Plugin directory (used with `--build`) | -| `--registry` | `https://marketplace.emdashcms.com` | Marketplace URL | +### Versions are immutable -## Complete workflow +A published version cannot be overwritten or republished. Bump `version` before publishing again. The build reads `version` from `package.json` (see [the manifest reference](/plugins/creating-plugins/manifest/#version-lives-in-packagejson)). Bump **major** for a broadened [trust contract](/plugins/creating-plugins/manifest/#trust-contract), **minor** for new hooks or routes, and **patch** for fixes. -Typical publish cycle: +### Publisher mismatch -```bash -# 1. Make your changes -# 2. Bump the version in src/index.ts and package.json -# 3. Bundle and publish -emdash plugin publish --build -``` +If `publish` fails with `MANIFEST_PUBLISHER_MISMATCH`, the active session is a different Atmosphere account than the manifest's pinned `publisher`. Switch to the pinned account with `emdash-plugin switch `, or update `publisher` in the manifest if you are genuinely transferring the plugin to a new account. See [Use an existing account](#use-an-existing-account) for managing sessions. -If you want to inspect the bundle first: +## What to read next -```bash -emdash plugin bundle -tar tzf dist/my-plugin-1.1.0.tar.gz -emdash plugin publish -``` +- [The `emdash-plugin` CLI](/plugins/creating-plugins/cli/) — every command +- [The manifest](/plugins/creating-plugins/manifest/) — fields, trust contract, publisher pinning +- [Capabilities and security](/plugins/creating-plugins/capabilities/) diff --git a/docs/src/content/docs/plugins/creating-plugins/settings.mdx b/docs/src/content/docs/plugins/creating-plugins/settings.mdx index fee500f58..4006d185d 100644 --- a/docs/src/content/docs/plugins/creating-plugins/settings.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/settings.mdx @@ -5,9 +5,9 @@ description: Per-plugin configuration through the KV store, exposed in the admin import { Aside } from "@astrojs/starlight/components"; -Sandboxed plugins store their settings in the per-plugin **KV store** and render the editing UI as a [Block Kit](/plugins/creating-plugins/block-kit/) page. The auto-generated `admin.settingsSchema` form that native plugins can use isn't available in the sandbox — instead, you describe the form in JSON and serve it from a route. +Sandboxed plugins store their settings in the per-plugin **KV store** and render the editing UI as a [Block Kit](/plugins/creating-plugins/block-kit/) page: you describe the form in JSON and serve it from a route. -It's a bit more work than `settingsSchema`, but everything happens through the same machinery the plugin already uses for hooks and routes — there's nothing extra to learn. +Everything happens through the same machinery the plugin already uses for hooks and routes — there's nothing extra to learn. ## The KV store @@ -67,9 +67,8 @@ await ctx.kv.set("url", url); Sandboxed plugins describe their settings page as Block Kit. The admin sends a `page_load` interaction to a route on your plugin (conventionally `routes.admin`), and the plugin returns a JSON description of the form. When the user clicks Save, the admin sends a `block_action` or `form_submit` interaction back; the plugin writes to KV and returns updated blocks. -```typescript title="src/sandbox-entry.ts" -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +```typescript title="src/plugin.ts" +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface BlockInteraction { type: "page_load" | "block_action" | "form_submit"; @@ -78,10 +77,10 @@ interface BlockInteraction { values?: Record; } -export default definePlugin({ +export default { routes: { admin: { - handler: async (routeCtx, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as BlockInteraction; if (interaction.type === "page_load" && interaction.page === "/settings") { @@ -100,7 +99,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; async function renderSettings(ctx: PluginContext) { const apiKey = (await ctx.kv.get("settings:apiKey")) ?? ""; @@ -150,10 +149,12 @@ async function saveSettings(ctx: PluginContext, values: Record) } ``` -To wire the settings page into the admin sidebar, declare it on the descriptor: +To wire the settings page into the admin sidebar, declare it in the manifest: -```typescript title="src/index.ts" -adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }], +```jsonc title="emdash-plugin.jsonc" +"admin": { + "pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }] +} ``` EmDash routes `page_load` interactions for that path to your `admin` route automatically. diff --git a/docs/src/content/docs/plugins/creating-plugins/storage.mdx b/docs/src/content/docs/plugins/creating-plugins/storage.mdx index 91afd419c..debcd5cc7 100644 --- a/docs/src/content/docs/plugins/creating-plugins/storage.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/storage.mdx @@ -5,76 +5,71 @@ description: Persist plugin data in document collections with indexed queries. import { Aside, Tabs, TabItem } from "@astrojs/starlight/components"; -Sandboxed plugins can store their own data in document collections. You declare collections and indexes on the plugin descriptor, and EmDash creates the schema automatically — no migrations to write. +Sandboxed plugins can store their own data in document collections. You declare collections and indexes in the [manifest](/plugins/creating-plugins/manifest/), and EmDash creates the schema automatically — no migrations to write. -This page covers sandboxed (standard-format) plugins. The collection API is identical for native plugins; the only difference is that native plugins declare `storage` directly inside `definePlugin()` rather than on a separate descriptor. +This page covers sandboxed plugins. The collection API is identical for native plugins; the only difference is that native plugins declare `storage` inside `definePlugin()` rather than in the manifest. -## Declaring storage on the descriptor +## Declaring storage in the manifest -For sandboxed plugins, `storage` lives on the descriptor — the file imported by `astro.config.mjs`, not the sandbox entry. Storage declarations need to be visible at build time so the sandbox bridge knows which collections the plugin is allowed to touch. +For sandboxed plugins, `storage` lives in `emdash-plugin.jsonc`. The declaration has to be visible at build time so the sandbox bridge knows which collections the plugin is allowed to touch. -```typescript title="src/index.ts" -import type { PluginDescriptor } from "emdash"; +```jsonc title="emdash-plugin.jsonc" +{ + "slug": "forms", + // ...identity + profile... -export function formsPlugin(): PluginDescriptor { - return { - id: "forms", - version: "1.0.0", - format: "standard", - entrypoint: "@my-org/plugin-forms/sandbox", - - storage: { - submissions: { - indexes: [ - "formId", - "status", - "createdAt", - ["formId", "createdAt"], - ["status", "createdAt"], - ], - }, - forms: { - indexes: ["slug"], - }, + "storage": { + "submissions": { + "indexes": [ + "formId", + "status", + "createdAt", + ["formId", "createdAt"], + ["status", "createdAt"] + ] }, - }; + "forms": { + "indexes": ["slug"] + } + } } ``` -Each key in `storage` is a collection name. The `indexes` array lists fields that can be queried efficiently — single-field indexes as strings, composite indexes as arrays of strings. +Each key in `storage` is a collection name. The `indexes` array lists fields that can be queried efficiently — single-field indexes as strings, composite indexes as arrays of strings. See [the manifest reference](/plugins/creating-plugins/manifest/#storage) for the full rules. -## Using storage in the sandbox entry +## Using storage at runtime -Inside the sandbox entry, access collections via `ctx.storage`. The shape mirrors what was declared on the descriptor: +In `src/plugin.ts`, access collections via `ctx.storage`. The shape mirrors what was declared in the manifest: -```typescript title="src/sandbox-entry.ts" -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; -export default definePlugin({ +export default { hooks: { - "content:afterSave": async (event, ctx: PluginContext) => { - const { submissions } = ctx.storage; - - await submissions.put("sub_123", { - formId: "contact", - email: "user@example.com", - status: "pending", - createdAt: new Date().toISOString(), - }); - - const item = await submissions.get("sub_123"); - ctx.log.info("Stored submission", { id: item?.formId }); + "content:afterSave": { + handler: async (event, ctx) => { + const { submissions } = ctx.storage; + + await submissions.put("sub_123", { + formId: "contact", + email: "user@example.com", + status: "pending", + createdAt: new Date().toISOString(), + }); + + const item = await submissions.get("sub_123"); + ctx.log.info("Stored submission", { id: item?.formId }); + }, }, }, -}); +} satisfies SandboxedPlugin; ``` -Accessing a collection that wasn't declared on the descriptor throws — the bridge enforces this at the runtime level. +Accessing a collection that wasn't declared in the manifest throws — the bridge enforces this at the runtime level. ## Collection API @@ -249,7 +244,8 @@ query({ where: { createdAt: { gte: "2024-01-01" } } }); // doe Cast collection access for IntelliSense on item shapes: ```typescript -import type { StorageCollection, PluginContext } from "emdash"; +import type { SandboxedPlugin } from "emdash/plugin"; +import type { StorageCollection } from "emdash"; interface Submission { formId: string; @@ -259,23 +255,27 @@ interface Submission { createdAt: string; } -export default definePlugin({ +export default { hooks: { - "content:afterSave": async (event, ctx: PluginContext) => { - const submissions = ctx.storage.submissions as StorageCollection; - - await submissions.put(`sub_${Date.now()}`, { - formId: "contact", - email: "user@example.com", - data: { message: "Hello" }, - status: "pending", - createdAt: new Date().toISOString(), - }); + "content:afterSave": { + handler: async (event, ctx) => { + const submissions = ctx.storage.submissions as StorageCollection; + + await submissions.put(`sub_${Date.now()}`, { + formId: "contact", + email: "user@example.com", + data: { message: "Hello" }, + status: "pending", + createdAt: new Date().toISOString(), + }); + }, }, }, -}); +} satisfies SandboxedPlugin; ``` +Both imports are type-only, so the bundler erases them — no `emdash` runtime enters the plugin bundle. + ## Storage vs content vs KV Pick the right mechanism for each kind of data: 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..24cc4d945 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 @@ -5,24 +5,32 @@ description: Build, register, and run a hello-world sandboxed plugin. import { Aside, Steps } from "@astrojs/starlight/components"; -This guide walks through building a minimal sandboxed plugin from scratch — a plugin that logs every content save and exposes a single API route. By the end you'll have a plugin that runs in an isolated runtime via the configured sandbox runner. The same plugin code can also run in-process if the site operator chooses to move it from `sandboxed: []` into `plugins: []` — for example on platforms without a sandbox runner available. +This guide builds a minimal sandboxed plugin from scratch. The plugin logs every content save and exposes a single API route. It runs in an isolated runtime provided by the configured sandbox runner. The same code also runs in-process when a site operator moves it from `sandboxed: []` into `plugins: []`, for example on a platform without a sandbox runner. -If you haven't decided yet whether you want a sandboxed or native plugin, read [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/) first. +If you haven't decided between sandboxed and native, read [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/) first. -## Two files + + +## Two pieces -Every sandboxed plugin ships two pieces: +A sandboxed plugin is: -1. **A descriptor** — a small object describing the plugin (id, version, capabilities, storage, where to find the runtime entry). Imported by `astro.config.mjs` at build time. -2. **A sandbox entry** — the runtime code: hooks, routes, storage access. Loaded into the sandbox runtime at request time. +1. **`emdash-plugin.jsonc`** — a hand-edited [manifest](/plugins/creating-plugins/manifest/): identity, the trust contract (capabilities, hosts, storage), and profile fields. No code. +2. **`src/plugin.ts`** — the runtime: hooks and routes. Type-only imports from `emdash/plugin`; no runtime `emdash` import. -The two files live in the same package and run in completely different environments. The descriptor never sees the runtime context; the entry never sees `astro.config.mjs`. +`emdash-plugin build` reads both and emits the `dist/` artifacts a site consumes. You do not write a descriptor or a build script. + +The following example shows the file layout of a complete plugin: ``` my-plugin/ +├── emdash-plugin.jsonc # Identity + trust contract + profile ├── src/ -│ ├── index.ts # Descriptor — runs in Vite at build time -│ └── sandbox-entry.ts # Hooks, routes, storage — runs in the sandbox runtime +│ └── plugin.ts # Hooks, routes — runs in the sandbox runtime ├── package.json └── tsconfig.json ``` @@ -31,7 +39,7 @@ my-plugin/ -1. Create a new directory and initialise it as a TypeScript ES module package. +1. Create the directory and a `package.json`. The build is `emdash-plugin build`; there is no `tsdown` invocation to write. ```json title="package.json" { @@ -44,24 +52,25 @@ my-plugin/ "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" + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" }, "peerDependencies": { - "emdash": "*" + "emdash": ">=0.12.0" }, "devDependencies": { - "emdash": "*", - "tsdown": "^0.6.0", - "typescript": "^5.5.0" + "@emdash-cms/plugin-cli": ">=0.1.0", + "emdash": ">=0.12.0", + "typescript": "^5.9.0" } } ``` - The `"./sandbox"` export is what the descriptor's `entrypoint` will point at. The bundler builds both files into `dist/`. + `"."` is the auto-generated descriptor a site imports. `"./sandbox"` is the built runtime file. The build produces both, so you never hand-write either. 2. Add a `tsconfig.json`: @@ -73,70 +82,60 @@ my-plugin/ "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, - "declaration": true, - "outDir": "./dist", - "rootDir": "./src" + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": [] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } ``` - +## Write the manifest -## Write the descriptor +`emdash-plugin.jsonc` carries the plugin's identity (`slug`), its trust contract (`capabilities`, `allowedHosts`, `storage`), profile fields, and the [publisher pin](/plugins/creating-plugins/manifest/#publisher-pinning). -The descriptor is a factory function that returns a `PluginDescriptor`. It runs in Vite at build time, which means it must be side-effect-free and can't use any runtime APIs (`fetch`, the database, environment variables — none of those exist yet). +The following example shows a complete manifest for the hello plugin: -```typescript title="src/index.ts" -import type { PluginDescriptor } from "emdash"; +```jsonc title="emdash-plugin.jsonc" +{ + "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", -export function helloPlugin(): PluginDescriptor { - return { - id: "plugin-hello", - version: "0.1.0", - format: "standard", - entrypoint: "@my-org/plugin-hello/sandbox", + "slug": "plugin-hello", + "publisher": "did:plc:abc123def456", // your Atmosphere account DID - capabilities: [], - storage: { - events: { indexes: ["timestamp"] }, - }, - }; + "license": "MIT", + "author": { "name": "Jane Doe", "url": "https://example.com" }, + "security": { "email": "security@example.com" }, + + "capabilities": [], + "allowedHosts": [], + "storage": { "events": { "indexes": ["timestamp"] } } } ``` -A few details that matter: +Notes on this manifest: -- **`format: "standard"` is required.** Without it, EmDash treats the package as a native plugin and looks for a different shape. The `format` field defaults to `"native"`. -- **`entrypoint` is a module specifier**, not a file path. Use the same string you'd pass to `import` — usually `"/sandbox"`. The package name can be scoped (`@my-org/plugin-hello`); the plugin `id` cannot. -- **`id` is a URL-safe slug, not the npm package name.** It must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The id is used both as a single path segment in plugin route URLs (`/_emdash/api/plugins//...`) and as part of generated SQL identifiers for plugin storage indexes, so `@`, `/`, leading digits, and uppercase letters all fail at runtime. Pair an unscoped `id` like `plugin-hello` with a scoped npm package name in `entrypoint`. -- **Capabilities, allowedHosts, and storage live on the descriptor.** The sandbox entry does not declare them — it can only use what the descriptor permits. -- **Don't put runtime logic here.** No top-level `await`, no module-level `fetch`, no reading files. The descriptor is metadata. +- **`slug` is a URL-safe id, not the npm package name.** `/^[a-z][a-z0-9_-]*$/`, max 64 chars. It's a single path segment in plugin route URLs (`/_emdash/api/plugins//...`) and part of generated SQL identifiers for storage indexes, so `@`, `/`, leading digits, and uppercase all fail. Pair an unscoped `slug` (`plugin-hello`) with a scoped npm package name. +- **`storage` declares collections up front.** `ctx.storage.events` works at runtime only because `events` is declared here. Accessing an undeclared collection throws. +- **`version` is omitted.** The build reads it from `package.json` so there's one source of truth. See [the manifest reference](/plugins/creating-plugins/manifest/#version-lives-in-packagejson). +- **The trust contract is consent.** Changing `capabilities`, `allowedHosts`, or `storage` later requires a version bump — installed sites consented to the old contract. -## Write the sandbox entry +## Write the runtime -The runtime side. This file is loaded inside the sandbox runtime at request time, with no access to anything except what `ctx` provides. +`src/plugin.ts` default-exports a bare object annotated with `satisfies SandboxedPlugin`. The import from `emdash/plugin` is type-only, so the `emdash` runtime does not enter the plugin bundle. -```typescript title="src/sandbox-entry.ts" -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +The following example logs every content save to plugin storage and exposes a `recent` route that returns the last ten saves: -interface ContentSaveEvent { - collection: string; - content: { id: string }; - isNew: boolean; -} +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; -export default definePlugin({ +export default { hooks: { "content:afterSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { ctx.log.info("Content saved", { collection: event.collection, id: event.content.id, @@ -153,47 +152,48 @@ export default definePlugin({ routes: { recent: { - handler: async (_routeCtx, ctx: PluginContext) => { + handler: async (_routeCtx, ctx) => { const result = await ctx.storage.events.query({ limit: 10 }); return { events: result.items }; }, }, }, -}); +} satisfies SandboxedPlugin; ``` -Things worth knowing: +Notes on the runtime file: -- **`definePlugin()` in the sandbox entry takes only `{ hooks, routes }`.** No `id`, no `version`, no `capabilities` — those come from the descriptor. EmDash throws at build time if you try to pass them here. -- **Hook handlers take `(event, ctx)`.** The event shape depends on the hook name; see the [Hooks reference](/plugins/creating-plugins/hooks/). -- **Route handlers take `(routeCtx, ctx)`** — two arguments. `routeCtx` has `{ input, request, requestMeta }`; `ctx` is the same `PluginContext` you get in hooks. Routes are reachable at `/_emdash/api/plugins//`. -- **`ctx.storage.events` works because `events` was declared on the descriptor.** Accessing an undeclared collection throws. -- **`ctx.kv` is always available** — a per-plugin key-value store with `get`, `set`, `delete`, and `list(prefix)`. +- **`satisfies SandboxedPlugin` types everything.** It infers `event` from the hook name (with the full canonical event type) and `ctx` as `PluginContext`, so handlers need no parameter annotations. A typo'd hook key like `"content:afterSav"` is a compile error. +- **Hook handlers take `(event, ctx)`.** The event shape depends on the hook name; see the [Hooks guide](/plugins/creating-plugins/hooks/). +- **Route handlers take `(routeCtx, ctx)`** — two arguments. `routeCtx` is `{ input, request, requestMeta? }`; `ctx` is the same `PluginContext`. Routes are reachable at `/_emdash/api/plugins//`. +- **`ctx.storage.events` works because `events` is declared in the manifest.** +- **`ctx.kv` is always available** — a per-plugin key-value store with `get`, `set`, `delete`, `list(prefix)`. ## 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 the site's `astro.config.mjs`, import the plugin's default export and pass it in. Sandboxed plugins go in `sandboxed: []`; in-process plugins go in `plugins: []`. A sandboxed plugin works in both. The example below uses `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(), }), ], }); ``` -`sandboxRunner` is the pluggable piece. The example above uses `sandbox()` from `@emdash-cms/cloudflare`, which is the sandbox runner most sites use today. Runners for other platforms are in development. If no runner is configured (or the configured runner reports as unavailable on the current platform), `sandboxed: []` plugins are skipped at startup — to run the same plugin in-process, move it from `sandboxed: []` into `plugins: []`. +`sandboxRunner` is the pluggable piece. The example uses `sandbox()` from `@emdash-cms/cloudflare`, the runner most sites use today. If no runner is configured (or the configured runner is unavailable on the current platform), `sandboxed: []` plugins are skipped at startup — move the plugin into `plugins: []` to run it in-process. ## Build and run @@ -209,15 +212,18 @@ export default defineConfig({ From the plugin directory: ```sh -pnpm build +emdash-plugin validate # schema-check the manifest first +emdash-plugin build # emit dist/ ``` -In the site directory, link or install the plugin (`pnpm add @my-org/plugin-hello` or a workspace link), then start the dev server. You should see `[hello] Content saved …` in the logs the next time you save a piece of content in the admin, and `GET /_emdash/api/plugins/plugin-hello/recent` should return the last ten save events. +For an edit loop, run `emdash-plugin dev` (rebuilds on save, keeps the last good `dist/` on a failed build). In the site, install or link the plugin (`pnpm add file:../plugin-hello` or a workspace link) and start the dev server. Save a piece of content in the admin and you should see `Content saved …` in the logs; `GET /_emdash/api/plugins/plugin-hello/recent` returns the last ten save events. ## What to read next -- [Hooks](/plugins/creating-plugins/hooks/) — the full set of events you can react to -- [API routes](/plugins/creating-plugins/api-routes/) — input validation, public routes, error handling +- [The manifest](/plugins/creating-plugins/manifest/) — every field, the trust contract, publisher pinning +- [The `emdash-plugin` CLI](/plugins/creating-plugins/cli/) — `build`, `dev`, `bundle` +- [Hooks](/plugins/creating-plugins/hooks/) — the full set of events +- [API routes](/plugins/creating-plugins/api-routes/) — input validation, public routes, errors - [Storage and KV](/plugins/creating-plugins/storage/) — query options, indexes, batch operations -- [Capabilities and security](/plugins/creating-plugins/capabilities/) — content access, network requests, host allowlists -- [Bundling and publishing](/plugins/creating-plugins/publishing/) — when you're ready to ship to the marketplace +- [Capabilities and security](/plugins/creating-plugins/capabilities/) — content access, network, host allowlists +- [Bundling and publishing](/plugins/creating-plugins/publishing/) — shipping to the marketplace diff --git a/docs/src/content/docs/plugins/upgrading-sites.mdx b/docs/src/content/docs/plugins/upgrading-sites.mdx new file mode 100644 index 000000000..763fab21b --- /dev/null +++ b/docs/src/content/docs/plugins/upgrading-sites.mdx @@ -0,0 +1,92 @@ +--- +title: Upgrading plugins on your site +description: Breaking changes for sites that install EmDash plugins, and how to adapt your project. +--- + +import { Aside } from "@astrojs/starlight/components"; + +This guide is for **site operators**: people who install plugins into a site. If you write plugins, see [Migrating to the plugin CLI](/plugins/creating-plugins/migrating-to-the-cli/) instead. + +## Upgrade your dependencies + +Update `emdash` and your plugin packages to their latest versions, then reinstall and rebuild: + +```sh +pnpm up emdash @emdash-cms/plugin-audit-log @emdash-cms/plugin-webhook-notifier @emdash-cms/plugin-atproto +pnpm build +``` + +After upgrading, your site may build and run without further changes. If the build fails or a plugin stops loading, work through the breaking changes below. Each one tells you exactly what to change. + +For the full list of changes in each package, see its entry in the [EmDash changelog](https://github.com/emdash-cms/emdash/blob/main/CHANGELOG.md). + +## Breaking changes + +### Renamed: `@emdash-cms/registry-cli` is now `@emdash-cms/plugin-cli` + +Earlier releases shipped the plugin registry CLI as `@emdash-cms/registry-cli`, with an `emdash-registry` binary. + +The package is now `@emdash-cms/plugin-cli` and the binary is `emdash-plugin`. The old package is no longer published. + +You only have this dependency if you publish plugins or run registry commands from your site repository. Most sites that only install plugins never had it. + +#### What should I do? + +Replace the package: + +```sh +pnpm remove @emdash-cms/registry-cli +pnpm add -D @emdash-cms/plugin-cli +``` + +Update any `package.json` scripts that call the old binary, replacing `emdash-registry` with `emdash-plugin`: + +```sh del={1} ins={2} +emdash-registry publish --url https://example.com/my-plugin-1.0.0.tar.gz +emdash-plugin publish --url https://example.com/my-plugin-1.0.0.tar.gz +``` + +### Changed: published plugins use a default export + +Earlier releases exposed first-party plugins as a named export and a factory call, for example `import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"` used as `auditLogPlugin()`. + +These plugins now provide a default export that you pass directly into `plugins:` or `sandboxed:`. There is no factory call. This affects `@emdash-cms/plugin-audit-log`, `@emdash-cms/plugin-webhook-notifier`, and `@emdash-cms/plugin-atproto`. + +#### What should I do? + +In `astro.config.mjs`, drop the braces around the import and the `()` after the plugin name. + +The following example shows the change for `@emdash-cms/plugin-audit-log`, which runs in-process and goes in `plugins:`: + +```js title="astro.config.mjs" del={1,7} ins={2,8} +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], + }), + ], +}); +``` + +Apply the same two edits to the other packages. `@emdash-cms/plugin-atproto` and `@emdash-cms/plugin-webhook-notifier` are sandboxed plugins, so they go in `sandboxed:` instead of `plugins:`; the import change is identical. + +| Package | Default export binding | +| ------------------------------------- | ---------------------- | +| `@emdash-cms/plugin-audit-log` | `auditLog` | +| `@emdash-cms/plugin-webhook-notifier` | `webhookNotifier` | +| `@emdash-cms/plugin-atproto` | `atproto` | + + + +## After upgrading + +If a third-party plugin still ships a named export and factory call, it has not been updated for this release. Check its changelog. All first-party plugins listed above use the default-export shape. From c8fe6ca363e19958d4ec482d6673b15add20343e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 16 May 2026 14:34:26 +0100 Subject: [PATCH 2/2] docs(plugins): apply definition-by-negation / builder-salience standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of #1059 against the standards formalized since it was written. Removed definition-by-negation and bundle-internal framing from the guide pages (migration guides left as-is — comparison/changelog is their purpose): - 'You do not write a descriptor or a build script' -> dropped (the positive sentence already says what build does) - 'the build produces both, so you never hand-write either' -> 'generates both' - 'type-only, so the emdash runtime does not enter the plugin bundle' / 'the bundler erases them — no emdash runtime enters' -> 'provides only types, so a sandboxed plugin has no runtime dependency on emdash' - 'you never write it by hand' -> 'EmDash derives ... automatically' - 'init never requires extra flags to succeed' -> 'A slug is the only required input' - 'wire-side filename' -> '(the filename the registry expects)' - 'The registry never stores your plugin's code' lead -> dropped; the positive 'you host the tarball; registry stores a link' carries it Tropes scan clean; em-dashes appositive; bold bullets definitional. --- docs/src/content/docs/plugins/creating-plugins/cli.mdx | 4 ++-- docs/src/content/docs/plugins/creating-plugins/manifest.mdx | 2 +- .../content/docs/plugins/creating-plugins/publishing.mdx | 2 +- docs/src/content/docs/plugins/creating-plugins/storage.mdx | 2 +- .../docs/plugins/creating-plugins/your-first-plugin.mdx | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/src/content/docs/plugins/creating-plugins/cli.mdx b/docs/src/content/docs/plugins/creating-plugins/cli.mdx index 0d187422a..f4c39bb8e 100644 --- a/docs/src/content/docs/plugins/creating-plugins/cli.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/cli.mdx @@ -47,7 +47,7 @@ Create a new plugin with `init`: npx @emdash-cms/plugin-cli init my-plugin ``` -This scaffolds a self-contained plugin: `emdash-plugin.jsonc`, `src/plugin.ts` (one example route in the `satisfies SandboxedPlugin` shape), `package.json`, `tsconfig.json`, a test, a README, and `.gitignore`. A scaffold created from just a slug is a valid starting point. The manifest carries `TODO:` comments for the few fields you must fill in — publisher, author, and security contact — before the plugin will load or publish. `init` never requires extra flags to succeed. +This scaffolds a self-contained plugin: `emdash-plugin.jsonc`, `src/plugin.ts` (one example route in the `satisfies SandboxedPlugin` shape), `package.json`, `tsconfig.json`, a test, a README, and `.gitignore`. A slug is the only required input. A scaffold created from just a slug is a valid starting point: the manifest carries `TODO:` comments for the few fields to fill in — publisher, author, and security contact — before the plugin will load or publish. ## `build` @@ -85,7 +85,7 @@ Offline schema check with `tsc`-style `file:line:column` diagnostics, including 1. Runs `build` to produce `dist/`. 2. Validates the bundle: no Node-builtin imports, no oversized files, capability sanity. 3. Collects optional assets — README, icon, screenshots. -4. Tarballs. Inside the tarball, `plugin.mjs` is packed as `backend.js` to match the registry's wire-side filename. The output is `dist/-.tar.gz`. +4. Tarballs. Inside the tarball, `plugin.mjs` is packed as `backend.js` (the filename the registry expects). The output is `dist/-.tar.gz`. diff --git a/docs/src/content/docs/plugins/creating-plugins/manifest.mdx b/docs/src/content/docs/plugins/creating-plugins/manifest.mdx index d0c198539..e99ca5c82 100644 --- a/docs/src/content/docs/plugins/creating-plugins/manifest.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/manifest.mdx @@ -50,7 +50,7 @@ The following example shows a complete manifest for an image-gallery plugin: | `publisher` | Yes | Your [Atmosphere account](/plugins/creating-plugins/publishing/#your-atmosphere-account)'s DID or handle. See [Publisher pinning](#publisher-pinning). | | `version` | No | Semver 2.0 without build-metadata. Usually omit it — see below. | -`slug` and `publisher` together are the package's identity. The registry derives the package's full identifier from them; you never write it by hand. +`slug` and `publisher` together are the package's identity. EmDash derives the package's full identifier from them automatically. ### `version` lives in `package.json` diff --git a/docs/src/content/docs/plugins/creating-plugins/publishing.mdx b/docs/src/content/docs/plugins/creating-plugins/publishing.mdx index e4bd36c3f..005b833b7 100644 --- a/docs/src/content/docs/plugins/creating-plugins/publishing.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/publishing.mdx @@ -7,7 +7,7 @@ import { Aside, Steps } from "@astrojs/starlight/components"; Once your sandboxed plugin works, publish it so other sites can install it. Publishing is sandboxed-only — native plugins distribute via npm. -When you publish, the CLI records the release to your own [Atmosphere account](#your-atmosphere-account). The registry never stores your plugin's code. **You host the tarball** yourself — a GitHub release asset, R2, S3, or any public URL — and the registry stores a link to it. +When you publish, the CLI records the release to your own [Atmosphere account](#your-atmosphere-account). **You host the tarball** yourself — a GitHub release asset, R2, S3, or any public URL — and the registry stores a link to it.