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.
Breaking changes
Section titled “Breaking changes”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.
What should I do?
Section titled “What should I do?”Replace the dependency:
pnpm remove @emdash-cms/registry-clipnpm add -D @emdash-cms/plugin-cliReplace 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.
What should I do?
Section titled “What should I do?”Replace every legacy name in the manifest:
| Earlier name | Current name |
|---|---|
network:fetch | network:request |
network:fetch:any | network:request:unrestricted |
read:content | content:read |
write:content | content:write |
read:media | media:read |
write:media | media:write |
read:users | users:read |
email:provide | hooks.email-transport:register |
email:intercept | hooks.email-events:register |
page:inject | hooks.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.
What should I do?
Section titled “What should I do?”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:
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.
What should I do?
Section titled “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:
{ "$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"]Changed: build with emdash-plugin build
Section titled “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?
Section titled “What should I do?”Replace the build script and add a watch script:
"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:
emdash-plugin validateemdash-plugin buildRemoved: 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.
What should I do?
Section titled “What should I do?”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.
What should I do?
Section titled “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:
import type { SandboxedPlugin } from "emdash";import type { SandboxedPluginInstance } from "emdash";Tell your users
Section titled “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 ().
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.
Verify the migrated plugin
Section titled “Verify the migrated plugin”Run the plugin’s tests, validate the authoring manifest, and execute the complete build and bundle checks:
pnpm testpnpm exec emdash-plugin validatepnpm exec emdash-plugin buildpnpm exec emdash-plugin bundle --validate-onlyThen 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.