Skip to content

Migrating to the plugin CLI

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 its entry on the releases page.

Renamed: @emdash-cms/registry-cli is now @emdash-cms/plugin-cli

Section titled “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.

Replace the dependency:

Terminal window
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.

Renamed: capability names use resource-first spelling

Section titled “Renamed: capability names use resource-first spelling”

Earlier manifests used capability names such as read:content and network:fetch. The authoring manifest accepts only the current names, although the runtime still normalizes legacy names in already-published bundles during the compatibility window.

Replace every legacy name in the manifest:

Earlier nameCurrent name
network:fetchnetwork:request
network:fetch:anynetwork:request:unrestricted
read:contentcontent:read
write:contentcontent:write
read:mediamedia:read
write:mediamedia:write
read:usersusers:read
email:providehooks.email-transport:register
email:intercepthooks.email-events:register
page:injecthooks.page-fragments:register

Use network:request with a non-empty allowedHosts list. Use network:request:unrestricted with an empty list only when an operator chooses the destination at runtime. Capabilities and security explains the current permissions and network rules.

Changed: sandboxed plugins use an explicit SandboxedPlugin annotation

Section titled “Changed: sandboxed plugins use an explicit SandboxedPlugin annotation”

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 assigns its definition to a SandboxedPlugin-typed constant and exports that constant as default. 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. The explicit annotation also keeps generated declarations portable under isolated package-manager layouts.

Make four changes to the plugin’s source file. Replace the import:

import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash";
import type { SandboxedPlugin } from "emdash/plugin";

Replace the definePlugin() wrapper with an explicitly typed constant:

export default definePlugin({ /* hooks, routes */ });
const plugin: SandboxedPlugin = { /* hooks, routes */ };
export default plugin;

Remove the parameter annotations from every handler:

handler: async (event: ContentHookEvent, ctx: PluginContext) => {
handler: async (event, ctx) => {

The result is one default-exported object:

src/plugin.ts
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
hooks: {
"content:beforeSave": {
handler: async (event, ctx) => {
return event.content;
},
},
},
};
export default plugin;

To name an event type in a helper function, import it from emdash/plugin:

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

Section titled “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.

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:

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 for every field, and Publisher pinning for the publisher field.

In package.json, point the "./sandbox" export at the built runtime file:

"./sandbox": "./dist/sandbox-entry.mjs"
"./sandbox": "./dist/plugin.mjs"

Add the manifest to files so it ships with the package:

"files": ["dist"]
"files": ["dist", "emdash-plugin.jsonc"]

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.

Replace the build script and add a watch script:

package.json
"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:

Terminal window
emdash-plugin validate
emdash-plugin build

Removed: standard-format type and function exports from emdash

Section titled “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.

Use SandboxedPlugin from emdash/plugin for the same purpose. A sandboxed plugin’s exported definition is already typed by its SandboxedPlugin annotation, so there is no replacement for isStandardPluginDefinition; identify a plugin by its structure ({ hooks?, routes? }) if you need to.

Renamed: sandbox-runner handles use SandboxedPluginInstance

Section titled “Renamed: sandbox-runner handles use SandboxedPluginInstance”

This affects only authors of a custom SandboxRunner, such as @emdash-cms/cloudflare. Most plugin authors can skip it.

The author-facing SandboxedPlugin type is available only from the type-only emdash/plugin entry point. The runtime handle returned by SandboxRunner.load is exported from emdash as SandboxedPluginInstance.

If you import SandboxedPlugin from emdash to type a sandbox runner or hold runtime plugin handles, change the import to SandboxedPluginInstance:

import type { SandboxedPlugin } from "emdash";
import type { SandboxedPluginInstance } from "emdash";

Sites that install your plugin also need to change their import. Point them at the new shape: drop the braces and the ().

astro.config.mjs
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, move that configuration to an admin settings page and read it from ctx.kv. Sandboxed plugin descriptors are plain objects and cannot receive constructor options. See Settings.

Run the plugin’s tests, validate the authoring manifest, and execute the complete build and bundle checks:

Terminal window
pnpm test
pnpm exec emdash-plugin validate
pnpm exec emdash-plugin build
pnpm exec emdash-plugin bundle --validate-only

Then install the local package into a development site and exercise each migrated hook and route. The build can confirm their names and shapes, but it cannot confirm that a route returns the intended data or that a hook preserves content correctly.