From 84c8f78f497127eb48a86721ab5c1bbedb2d54e4 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 14 May 2026 13:58:38 +0100 Subject: [PATCH 01/16] feat(registry-cli): extend manifest schema with identity + trust contract First phase of the sandboxed plugin redesign (#1028b). Adds the manifest fields that make `src/index.ts` and the in-code descriptor factory redundant. The trust contract is now hand-authored in the manifest, where a security reviewer can find it without grep. New required fields: - `slug`: ASCII letter then letters/digits/hyphens/underscores, max 64 chars. Matches the registry lexicon's rkey grammar via the shared PLUGIN_SLUG_RE in @emdash-cms/plugin-types. - `version`: semver 2.0 subset, no build-metadata (atproto rkeys can't contain `+`). Validated via PLUGIN_VERSION_RE. - `publisher`: now required (was optional in #1028a). The runtime cannot compute the plugin's AT URI without it; making it optional meant the plugin couldn't load locally before first publish. New optional fields with sensible defaults: - `capabilities`: array of capability strings. Defaults to []. Each entry validated against the current vocabulary; deprecated names are hard-rejected with a hint at the replacement (no deprecation window for new authoring). - `allowedHosts`: array of host patterns. Defaults to []. Required non-empty when `network:request` is declared without `:unrestricted`. Forbidden when `:unrestricted` is declared. - `storage`: map of collection name -> { indexes, uniqueIndexes? }. Defaults to {}. The cross-field rule for network:request / allowedHosts mirrors the release-extension lexicon's networkRequestConstraints behaviour, so authors hit the schema error here rather than a PDS validation error at publish time. Schema regenerated. 33 new tests; 204 total passing. Part of #1028b. The bundle rewrite, init command, plugin migrations, and `localPlugin` dev helper land in subsequent commits. --- .../schemas/emdash-plugin.schema.json | 236 +++++++++++--- packages/registry-cli/src/manifest/schema.ts | 268 +++++++++++++++- .../registry-cli/tests/manifest-load.test.ts | 6 + .../tests/manifest-publisher.test.ts | 21 +- .../tests/manifest-schema.test.ts | 53 +-- .../tests/manifest-trust-contract.test.ts | 302 ++++++++++++++++++ 6 files changed, 821 insertions(+), 65 deletions(-) create mode 100644 packages/registry-cli/tests/manifest-trust-contract.test.ts diff --git a/packages/registry-cli/schemas/emdash-plugin.schema.json b/packages/registry-cli/schemas/emdash-plugin.schema.json index 2615dcbe0..b1c29319f 100644 --- a/packages/registry-cli/schemas/emdash-plugin.schema.json +++ b/packages/registry-cli/schemas/emdash-plugin.schema.json @@ -8,39 +8,60 @@ "$schema": { "$ref": "#/$defs/__schema0" }, - "license": { + "slug": { "$ref": "#/$defs/__schema1" }, - "publisher": { + "version": { "$ref": "#/$defs/__schema2" }, - "author": { + "license": { "$ref": "#/$defs/__schema3" }, + "publisher": { + "$ref": "#/$defs/__schema4" + }, + "capabilities": { + "$ref": "#/$defs/__schema5" + }, + "allowedHosts": { + "$ref": "#/$defs/__schema7" + }, + "storage": { + "$ref": "#/$defs/__schema9" + }, + "author": { + "$ref": "#/$defs/__schema16" + }, "authors": { - "$ref": "#/$defs/__schema8" + "$ref": "#/$defs/__schema21" }, "security": { - "$ref": "#/$defs/__schema9" + "$ref": "#/$defs/__schema22" }, "securityContacts": { - "$ref": "#/$defs/__schema13" + "$ref": "#/$defs/__schema26" }, "name": { - "$ref": "#/$defs/__schema14" + "$ref": "#/$defs/__schema27" }, "description": { - "$ref": "#/$defs/__schema15" + "$ref": "#/$defs/__schema28" }, "keywords": { - "$ref": "#/$defs/__schema16" + "$ref": "#/$defs/__schema29" }, "repo": { - "$ref": "#/$defs/__schema18" + "$ref": "#/$defs/__schema31" } }, "required": [ - "license" + "slug", + "version", + "license", + "publisher", + "capabilities", + "allowedHosts", + "storage" ], "additionalProperties": false, "$defs": { @@ -49,6 +70,32 @@ "description": "Path or URL to the JSON Schema describing this file. Editors use this for completion and validation." }, "__schema1": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]*$", + "title": "Slug", + "description": "URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.", + "examples": [ + "gallery", + "image-resizer", + "my-plugin" + ] + }, + "__schema2": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", + "title": "Version", + "description": "Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.", + "examples": [ + "0.1.0", + "1.2.3", + "1.0.0-rc.1" + ] + }, + "__schema3": { "type": "string", "minLength": 1, "maxLength": 256, @@ -60,7 +107,7 @@ "MIT OR Apache-2.0" ] }, - "__schema2": { + "__schema4": { "type": "string", "title": "Publisher", "description": "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", @@ -69,20 +116,133 @@ "example.com" ] }, - "__schema3": { - "$ref": "#/$defs/__schema4" + "__schema5": { + "default": [], + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema6" + }, + "title": "Capabilities", + "description": "Trust contract: what runtime APIs the plugin is allowed to use. Changing this between releases requires a version bump because installed users have consented to the old contract." }, - "__schema4": { + "__schema6": { + "type": "string", + "minLength": 1 + }, + "__schema7": { + "default": [], + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/__schema8" + }, + "title": "Allowed hosts", + "description": "Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.", + "examples": [ + [ + "api.example.com", + "*.cdn.example.com" + ] + ] + }, + "__schema8": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "__schema9": { + "default": {}, + "type": "object", + "propertyNames": { + "$ref": "#/$defs/__schema10" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema11" + }, + "title": "Storage", + "description": "Storage collections the plugin uses. Each collection is namespaced to this plugin at runtime." + }, + "__schema10": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "__schema11": { + "type": "object", + "properties": { + "indexes": { + "$ref": "#/$defs/__schema12" + }, + "uniqueIndexes": { + "$ref": "#/$defs/__schema14" + } + }, + "required": [ + "indexes" + ], + "additionalProperties": false, + "title": "Storage collection", + "description": "Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names)." + }, + "__schema12": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema13" + } + } + ] + } + }, + "__schema13": { + "type": "string", + "minLength": 1 + }, + "__schema14": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema15" + } + } + ] + } + }, + "__schema15": { + "type": "string", + "minLength": 1 + }, + "__schema16": { + "$ref": "#/$defs/__schema17" + }, + "__schema17": { "type": "object", "properties": { "name": { - "$ref": "#/$defs/__schema5" + "$ref": "#/$defs/__schema18" }, "url": { - "$ref": "#/$defs/__schema6" + "$ref": "#/$defs/__schema19" }, "email": { - "$ref": "#/$defs/__schema7" + "$ref": "#/$defs/__schema20" } }, "required": [ @@ -92,104 +252,104 @@ "title": "Author", "description": "A single author entry. Mirrors the lexicon's author shape." }, - "__schema5": { + "__schema18": { "type": "string", "minLength": 1, "maxLength": 256, "description": "Display name." }, - "__schema6": { + "__schema19": { "type": "string", "maxLength": 1024, "format": "uri", "description": "Author's homepage or profile URL. Either this or `email` is recommended." }, - "__schema7": { + "__schema20": { "type": "string", "maxLength": 256, "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "description": "Author's contact email. Either this or `url` is recommended." }, - "__schema8": { + "__schema21": { "minItems": 1, "maxItems": 32, "type": "array", "items": { - "$ref": "#/$defs/__schema4" + "$ref": "#/$defs/__schema17" }, "title": "Authors (multiple)", "description": "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one." }, - "__schema9": { - "$ref": "#/$defs/__schema10" + "__schema22": { + "$ref": "#/$defs/__schema23" }, - "__schema10": { + "__schema23": { "type": "object", "properties": { "url": { - "$ref": "#/$defs/__schema11" + "$ref": "#/$defs/__schema24" }, "email": { - "$ref": "#/$defs/__schema12" + "$ref": "#/$defs/__schema25" } }, "additionalProperties": false, "title": "Security contact", "description": "A single security contact. At least one of `url` or `email` must be present." }, - "__schema11": { + "__schema24": { "type": "string", "maxLength": 1024, "format": "uri", "description": "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required." }, - "__schema12": { + "__schema25": { "type": "string", "maxLength": 256, "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "description": "Security contact email. Either this or `url` is required." }, - "__schema13": { + "__schema26": { "minItems": 1, "maxItems": 8, "type": "array", "items": { - "$ref": "#/$defs/__schema10" + "$ref": "#/$defs/__schema23" }, "title": "Security contacts (multiple)", "description": "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one." }, - "__schema14": { + "__schema27": { "type": "string", "minLength": 1, "maxLength": 1024, "title": "Display name", "description": "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted." }, - "__schema15": { + "__schema28": { "type": "string", "minLength": 1, "maxLength": 1024, "title": "Description", "description": "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists." }, - "__schema16": { + "__schema29": { "maxItems": 5, "type": "array", "items": { - "$ref": "#/$defs/__schema17" + "$ref": "#/$defs/__schema30" }, "title": "Keywords", "description": "Search keywords (<= 5 entries, FAIR convention)." }, - "__schema17": { + "__schema30": { "type": "string", "minLength": 1, "maxLength": 128 }, - "__schema18": { + "__schema31": { "type": "string", "maxLength": 1024, "format": "uri", diff --git a/packages/registry-cli/src/manifest/schema.ts b/packages/registry-cli/src/manifest/schema.ts index 3a5b5de1d..f8795570f 100644 --- a/packages/registry-cli/src/manifest/schema.ts +++ b/packages/registry-cli/src/manifest/schema.ts @@ -51,6 +51,15 @@ */ import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import { + CAPABILITY_RENAMES, + isDeprecatedCapability, + normalizeCapability, + PLUGIN_SLUG_MAX_LENGTH, + PLUGIN_SLUG_RE, + PLUGIN_VERSION_MAX_LENGTH, + PLUGIN_VERSION_RE, +} from "@emdash-cms/plugin-types"; import { z } from "zod"; // ────────────────────────────────────────────────────────────────────────── @@ -236,6 +245,207 @@ export const RepoSchema = z examples: ["https://github.com/emdash-cms/plugin-gallery"], }); +// ────────────────────────────────────────────────────────────────────────── +// Identity (slug + version) +// ────────────────────────────────────────────────────────────────────────── + +/** + * The plugin's slug. ASCII letter then letters/digits/hyphens/underscores, + * max 64 chars. Same constraints as the registry lexicon's `rkey`-portion + * of a release record, validated via the shared `PLUGIN_SLUG_RE` in + * `@emdash-cms/plugin-types`. + * + * Slug + publisher together form the package identity. The runtime derives + * the AT URI from them; the author never writes the URI directly. + */ +export const SlugSchema = z + .string() + .min(1, "slug must be a non-empty string") + .max(PLUGIN_SLUG_MAX_LENGTH, `slug must be <= ${PLUGIN_SLUG_MAX_LENGTH} characters`) + .regex( + PLUGIN_SLUG_RE, + 'slug must start with a lowercase letter, then lowercase letters / digits / "-" / "_" (e.g. "gallery", "my-plugin")', + ) + .meta({ + title: "Slug", + description: + "URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.", + examples: ["gallery", "image-resizer", "my-plugin"], + }); + +/** + * The plugin's version. Subset of semver 2.0; build-metadata (`+...`) is + * disallowed because atproto record keys can't contain `+`. Validated via + * `PLUGIN_VERSION_RE` from `@emdash-cms/plugin-types`. + */ +export const VersionSchema = z + .string() + .min(1, "version must be a non-empty string") + .max(PLUGIN_VERSION_MAX_LENGTH, `version must be <= ${PLUGIN_VERSION_MAX_LENGTH} characters`) + .regex( + PLUGIN_VERSION_RE, + 'version must follow semver 2.0 without build-metadata (e.g. "0.1.0", "1.2.3-rc.1")', + ) + .meta({ + title: "Version", + description: + "Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.", + examples: ["0.1.0", "1.2.3", "1.0.0-rc.1"], + }); + +// ────────────────────────────────────────────────────────────────────────── +// Trust contract (capabilities + allowedHosts + storage) +// ────────────────────────────────────────────────────────────────────────── + +/** + * The set of currently-valid (non-deprecated) capability names. + * + * Mirrors the `CurrentPluginCapability` union from `@emdash-cms/plugin-types`. + * TS unions don't survive erasure into a runtime Set, so we maintain the + * list here and the schema's tests catch drift against the type definition. + */ +const CURRENT_CAPABILITIES = new Set([ + "network:request", + "network:request:unrestricted", + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "email:send", + "hooks.email-transport:register", + "hooks.email-events:register", + "hooks.page-fragments:register", +]); + +/** + * A single capability declaration. Plain string, validated for membership + * in the current vocabulary AND for being non-deprecated. Deprecated names + * are hard-rejected with a hint pointing at the replacement — the deprecation + * window is for already-published plugins, not for new authoring. + * + * Uses a single `superRefine` so we can produce an issue-specific message + * that names the offending capability string. The shape mirrors Zod 4's + * recommended pattern for "value-dependent error messages". + */ +export const CapabilitySchema = z + .string() + .min(1, "capability must be a non-empty string") + .superRefine((cap, ctx) => { + if (isDeprecatedCapability(cap)) { + const replacement = CAPABILITY_RENAMES[cap]; + ctx.addIssue({ + code: "custom", + message: `capability "${cap}" is deprecated. Use "${replacement}" instead.`, + }); + return; + } + const normalised = normalizeCapability(cap); + if (!CURRENT_CAPABILITIES.has(normalised)) { + ctx.addIssue({ + code: "custom", + message: `capability "${cap}" is not a recognised name. See the docs for the available capabilities.`, + }); + } + }); + +/** + * Capabilities array. The plugin's declared trust contract. Empty array + * (or omitted field, defaulting to empty) means the plugin asks for no + * privileges beyond the built-in surface (logging, kv, routes/hooks + * registration). + * + * Cross-field rule (in `ManifestSchema`'s `.refine()`): if `capabilities` + * includes `network:request` (and NOT `network:request:unrestricted`), + * then `allowedHosts` must be a non-empty array. This matches the + * `releaseExtension` lexicon's `networkRequestConstraints.allowedHosts` + * "absent OR non-empty" rule. + */ +export const CapabilitiesSchema = z + .array(CapabilitySchema) + .max(32, "capabilities[] must have <= 32 entries") + .meta({ + title: "Capabilities", + description: + "Trust contract: what runtime APIs the plugin is allowed to use. Changing this between releases requires a version bump because installed users have consented to the old contract.", + }); + +/** + * Slash or whitespace in a hostname pattern is a sign the user pasted a + * URL or path instead of a bare host. Hoisted out of `.refine()` so the + * regex is compiled once. + */ +const HOST_PATTERN_INVALID_CHARS = /[/\s]/; + +/** + * Allowed-hosts list for `network:request`. Each entry is a hostname + * pattern with no scheme/path/whitespace; a leading `*.` permits + * subdomains. (Ports are accepted by this loose check; the publish-time + * lexicon validator is the strict authority on the exact grammar.) + */ +export const AllowedHostsSchema = z + .array( + z + .string() + .min(1, "host pattern must be non-empty") + .max(256, "host pattern must be <= 256 characters") + .refine( + (h) => !HOST_PATTERN_INVALID_CHARS.test(h) && !h.includes("://"), + 'host pattern must be a hostname only (no scheme, path, or whitespace; "*." for subdomain wildcard is allowed)', + ), + ) + .max(64, "allowedHosts[] must have <= 64 entries") + .meta({ + title: "Allowed hosts", + description: + "Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.", + examples: [["api.example.com", "*.cdn.example.com"]], + }); + +/** + * Storage collection config. Mirrors `StorageCollectionConfig` from + * `@emdash-cms/plugin-types`. Indexes are field names (or composite + * arrays). Unique indexes are queryable too — don't duplicate them in + * `indexes`. + */ +export const StorageCollectionSchema = z + .object({ + indexes: z.array(z.union([z.string().min(1), z.array(z.string().min(1)).min(1)])), + uniqueIndexes: z + .array(z.union([z.string().min(1), z.array(z.string().min(1)).min(1)])) + .optional(), + }) + .strict() + .meta({ + title: "Storage collection", + description: + "Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names).", + }); + +/** + * Storage declaration. Map of collection name to its index config. + * Collection names follow the same slug-like rules as plugin slugs: + * lowercase letters, digits, hyphens, underscores. The runtime uses the + * collection name verbatim as the SQL table-suffix, so the grammar must + * be safe. + */ +export const StorageSchema = z + .record( + z + .string() + .min(1, "storage collection name must be non-empty") + .regex( + /^[a-z][a-z0-9_]*$/, + 'storage collection name must start with a lowercase letter, then lowercase letters / digits / "_"', + ), + StorageCollectionSchema, + ) + .meta({ + title: "Storage", + description: + "Storage collections the plugin uses. Each collection is namespaced to this plugin at runtime.", + }); + // ────────────────────────────────────────────────────────────────────────── // Top-level manifest // ────────────────────────────────────────────────────────────────────────── @@ -264,14 +474,30 @@ export const ManifestSchema = z }) .optional(), + // Identity. Slug + publisher together form the package's identity; + // the AT URI is derived at runtime, never authored. + slug: SlugSchema, + version: VersionSchema, + // Required on first publish, ignored on subsequent publishes (the // existing profile wins). Same precedence rules as today's // --license flag. license: LicenseSchema, - // Optional publisher pin. Omitted on first publish, the CLI - // writes the active session's DID back here automatically. - publisher: PublisherSchema.optional(), + // Publisher pin. Required for the plugin to load — the runtime + // can't compute the AT URI without it. Authors fill it in before + // first run; on first publish, if the value matches the session, + // it stays. If a publisher migrates the manifest's `publisher` + // must be updated explicitly. + publisher: PublisherSchema, + + // Trust contract. Static for a given version; changes require + // a version bump because installed users have consented to the + // old contract. Default-empty so the minimal manifest doesn't + // need to spell out the absence of privileges. + capabilities: CapabilitiesSchema.default([]), + allowedHosts: AllowedHostsSchema.default([]), + storage: StorageSchema.default({}), // Single-author form. Mutually exclusive with `authors`. author: AuthorSchema.optional(), @@ -329,6 +555,42 @@ export const ManifestSchema = z message: "manifest must specify either `security: { ... }` or `securityContacts: [...]`", path: ["security"], }) + .refine( + (v) => { + // network:request without :unrestricted requires a non-empty + // allowedHosts. Without this guard, the lexicon's + // networkRequestConstraints rule fires at publish time and + // users see a confusing PDS error rather than a schema error. + const caps = new Set((v.capabilities ?? []).map((c) => normalizeCapability(c))); + if (caps.has("network:request") && !caps.has("network:request:unrestricted")) { + return (v.allowedHosts ?? []).length > 0; + } + return true; + }, + { + message: + 'capability "network:request" requires a non-empty `allowedHosts` list. Either add hosts, or upgrade to "network:request:unrestricted" if the plugin really needs to call any host.', + path: ["allowedHosts"], + }, + ) + .refine( + (v) => { + // network:request:unrestricted with allowedHosts is contradictory + // — the unrestricted capability says "any host", but the list + // implies "only these". The lexicon's rule is "allowedHosts + // MUST NOT appear when unrestricted"; same here. + const caps = new Set((v.capabilities ?? []).map((c) => normalizeCapability(c))); + if (caps.has("network:request:unrestricted")) { + return (v.allowedHosts ?? []).length === 0; + } + return true; + }, + { + message: + '`allowedHosts` must be empty when "network:request:unrestricted" is declared (the unrestricted capability already grants any host).', + path: ["allowedHosts"], + }, + ) .meta({ title: "EmDash plugin manifest", description: diff --git a/packages/registry-cli/tests/manifest-load.test.ts b/packages/registry-cli/tests/manifest-load.test.ts index 480f5cb3e..78cc56f40 100644 --- a/packages/registry-cli/tests/manifest-load.test.ts +++ b/packages/registry-cli/tests/manifest-load.test.ts @@ -28,6 +28,9 @@ import { } from "../src/manifest/load.js"; const MINIMAL = `{ + "slug": "my-plugin", + "version": "0.1.0", + "publisher": "example.com", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -45,6 +48,9 @@ describe("parseAndValidateManifest (in-memory)", () => { it("accepts JSONC features (comments + trailing commas)", () => { const source = `{ // top-level comment + "slug": "my-plugin", + "version": "0.1.0", + "publisher": "example.com", "license": "MIT", /* block comment */ "author": { "name": "Jane Doe", }, "security": { "email": "security@example.com", }, diff --git a/packages/registry-cli/tests/manifest-publisher.test.ts b/packages/registry-cli/tests/manifest-publisher.test.ts index 83d70e5fa..5dc75152c 100644 --- a/packages/registry-cli/tests/manifest-publisher.test.ts +++ b/packages/registry-cli/tests/manifest-publisher.test.ts @@ -58,6 +58,8 @@ describe("PublisherSchema", () => { describe("ManifestSchema with publisher", () => { const minimal = { + slug: "my-plugin", + version: "0.1.0", license: "MIT", author: { name: "Jane Doe" }, security: { email: "security@example.com" }, @@ -79,9 +81,12 @@ describe("ManifestSchema with publisher", () => { expect(result.success).toBe(true); }); - it("accepts a manifest without a publisher (first-publish state)", () => { + it("rejects a manifest without a publisher", () => { + // publisher is required for the runtime to compute the plugin's + // AT URI. The author must fill it in before any local-dev or + // publish run. const result = ManifestSchema.safeParse(minimal); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); it("rejects a manifest with an invalid publisher", () => { @@ -146,6 +151,8 @@ describe("writePublisherBack", () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ // Top-level comment + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -171,6 +178,8 @@ describe("writePublisherBack", () => { it("appends a // comment when a session handle is provided", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -202,6 +211,8 @@ describe("writePublisherBack", () => { // after a maintainer transfer) would have triggered this. const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "description": "Originally published as ${SESSION_DID}. See changelog.", "author": { "name": "Jane Doe" }, @@ -229,6 +240,8 @@ describe("writePublisherBack", () => { it("omits the comment when no handle is provided", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } @@ -274,6 +287,8 @@ describe("writePublisherBack", () => { it("does not overwrite an existing publisher (defensive re-parse)", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "publisher": "did:plc:user-pinned-already", "author": { "name": "Jane Doe" }, @@ -326,6 +341,8 @@ describe("writePublisherBack", () => { it("produces a JSONC document that round-trips through the loader", async () => { const path = join(dir, "emdash-plugin.jsonc"); const source = `{ + "slug": "my-plugin", + "version": "0.1.0", "license": "MIT", "author": { "name": "Jane Doe" }, "security": { "email": "security@example.com" } diff --git a/packages/registry-cli/tests/manifest-schema.test.ts b/packages/registry-cli/tests/manifest-schema.test.ts index a3a28a636..49e150e5f 100644 --- a/packages/registry-cli/tests/manifest-schema.test.ts +++ b/packages/registry-cli/tests/manifest-schema.test.ts @@ -123,6 +123,9 @@ describe("RepoSchema", () => { describe("ManifestSchema (full document)", () => { const minimal = { + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", license: "MIT", author: { name: "Jane Doe" }, security: { email: "security@example.com" }, @@ -143,6 +146,9 @@ describe("ManifestSchema (full document)", () => { it("accepts the multi-author/multi-contact form", () => { const result = ManifestSchema.safeParse({ + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", license: "MIT", authors: [{ name: "Alice" }, { name: "Bob" }], securityContacts: [{ email: "alice@example.com" }, { url: "https://example.com/security" }], @@ -152,49 +158,47 @@ describe("ManifestSchema (full document)", () => { it("rejects mixing `author` and `authors`", () => { const result = ManifestSchema.safeParse({ - license: "MIT", - author: { name: "Alice" }, + ...minimal, authors: [{ name: "Bob" }], - security: { email: "security@example.com" }, }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("both `author` and `authors`"); + expect(result.error.issues.some((i) => i.message.includes("both `author` and `authors`"))).toBe( + true, + ); } }); it("rejects mixing `security` and `securityContacts`", () => { const result = ManifestSchema.safeParse({ - license: "MIT", - author: { name: "Alice" }, - security: { email: "a@example.com" }, + ...minimal, securityContacts: [{ email: "b@example.com" }], }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("both `security` and `securityContacts`"); + expect( + result.error.issues.some((i) => + i.message.includes("both `security` and `securityContacts`"), + ), + ).toBe(true); } }); it("requires either `author` or `authors`", () => { - const result = ManifestSchema.safeParse({ - license: "MIT", - security: { email: "security@example.com" }, - }); + const { author: _author, ...withoutAuthor } = minimal; + const result = ManifestSchema.safeParse(withoutAuthor); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("`author: { ... }`"); + expect(result.error.issues.some((i) => i.message.includes("`author: { ... }`"))).toBe(true); } }); it("requires either `security` or `securityContacts`", () => { - const result = ManifestSchema.safeParse({ - license: "MIT", - author: { name: "Alice" }, - }); + const { security: _security, ...withoutSecurity } = minimal; + const result = ManifestSchema.safeParse(withoutSecurity); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues[0]?.message).toContain("`security: { ... }`"); + expect(result.error.issues.some((i) => i.message.includes("`security: { ... }`"))).toBe(true); } }); @@ -207,20 +211,20 @@ describe("ManifestSchema (full document)", () => { }); it("rejects an empty authors array (lexicon requires >= 1)", () => { + const { author: _author, ...rest } = minimal; const result = ManifestSchema.safeParse({ - license: "MIT", + ...rest, authors: [], - security: { email: "security@example.com" }, }); expect(result.success).toBe(false); }); it("rejects more than 32 authors (lexicon cap)", () => { const authors = Array.from({ length: 33 }, (_, i) => ({ name: `Author ${i}` })); + const { author: _author, ...rest } = minimal; const result = ManifestSchema.safeParse({ - license: "MIT", + ...rest, authors, - security: { email: "security@example.com" }, }); expect(result.success).toBe(false); }); @@ -236,6 +240,9 @@ describe("ManifestSchema (full document)", () => { it("accepts a full populated manifest", () => { const result = ManifestSchema.safeParse({ $schema: "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", + slug: "gallery", + version: "0.1.0", + publisher: "example.com", license: "MIT", author: { name: "Jane Doe", @@ -250,6 +257,8 @@ describe("ManifestSchema (full document)", () => { description: "Image gallery block for EmDash.", keywords: ["gallery", "images", "media"], repo: "https://github.com/emdash-cms/plugin-gallery", + capabilities: ["content:read"], + storage: { events: { indexes: ["timestamp"] } }, }); expect(result.success).toBe(true); }); diff --git a/packages/registry-cli/tests/manifest-trust-contract.test.ts b/packages/registry-cli/tests/manifest-trust-contract.test.ts new file mode 100644 index 000000000..1e3e812f9 --- /dev/null +++ b/packages/registry-cli/tests/manifest-trust-contract.test.ts @@ -0,0 +1,302 @@ +/** + * Coverage for the manifest's identity (`slug`, `version`) and + * trust-contract fields (`capabilities`, `allowedHosts`, `storage`). + * + * The trust contract is the manifest's most security-sensitive surface. + * Capability vocabulary, deprecated-name rejection, and the cross-field + * `network:request` / `allowedHosts` rules all need explicit coverage so + * a future schema edit can't silently relax the validation. + */ + +import { describe, expect, it } from "vitest"; + +import { + AllowedHostsSchema, + CapabilitiesSchema, + CapabilitySchema, + ManifestSchema, + SlugSchema, + StorageSchema, + VersionSchema, +} from "../src/manifest/schema.js"; + +describe("SlugSchema", () => { + it("accepts the canonical form", () => { + expect(SlugSchema.parse("gallery")).toBe("gallery"); + expect(SlugSchema.parse("my-plugin")).toBe("my-plugin"); + expect(SlugSchema.parse("plugin_v2")).toBe("plugin_v2"); + }); + + it("rejects leading digit", () => { + const result = SlugSchema.safeParse("1-plugin"); + expect(result.success).toBe(false); + }); + + it("rejects leading punctuation", () => { + expect(SlugSchema.safeParse("-plugin").success).toBe(false); + expect(SlugSchema.safeParse("_plugin").success).toBe(false); + }); + + it("rejects uppercase", () => { + const result = SlugSchema.safeParse("MyPlugin"); + expect(result.success).toBe(false); + }); + + it("rejects empty", () => { + expect(SlugSchema.safeParse("").success).toBe(false); + }); + + it("rejects over 64 chars", () => { + const result = SlugSchema.safeParse("a".repeat(65)); + expect(result.success).toBe(false); + }); +}); + +describe("VersionSchema", () => { + it("accepts the canonical form", () => { + expect(VersionSchema.parse("0.1.0")).toBe("0.1.0"); + expect(VersionSchema.parse("1.2.3")).toBe("1.2.3"); + expect(VersionSchema.parse("1.0.0-rc.1")).toBe("1.0.0-rc.1"); + }); + + it("rejects build metadata (atproto rkey constraint)", () => { + // The atproto record-key alphabet has no `+`, so a semver + // build-metadata suffix can't survive into the publish path. + const result = VersionSchema.safeParse("1.0.0+build.1"); + expect(result.success).toBe(false); + }); + + it("rejects malformed semver", () => { + expect(VersionSchema.safeParse("1").success).toBe(false); + expect(VersionSchema.safeParse("1.0").success).toBe(false); + expect(VersionSchema.safeParse("v1.0.0").success).toBe(false); + }); +}); + +describe("CapabilitySchema", () => { + it("accepts a current capability", () => { + expect(CapabilitySchema.parse("content:read")).toBe("content:read"); + expect(CapabilitySchema.parse("network:request")).toBe("network:request"); + expect(CapabilitySchema.parse("email:send")).toBe("email:send"); + }); + + it("rejects a deprecated capability with a hint at the replacement", () => { + const result = CapabilitySchema.safeParse("read:content"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("deprecated"); + expect(result.error.issues[0]?.message).toContain("content:read"); + } + }); + + it("rejects an unknown capability", () => { + const result = CapabilitySchema.safeParse("filesystem:write"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("not a recognised name"); + } + }); + + it("rejects empty string", () => { + expect(CapabilitySchema.safeParse("").success).toBe(false); + }); +}); + +describe("CapabilitiesSchema (array-level)", () => { + it("accepts an empty list (no privileges beyond defaults)", () => { + expect(CapabilitiesSchema.parse([])).toEqual([]); + }); + + it("rejects more than 32 entries", () => { + // All entries are valid capabilities but the count alone should + // trip the array max. Repeat a valid one rather than constructing + // 33 distinct names that would also each fail individually. + const result = CapabilitiesSchema.safeParse(Array.from({ length: 33 }).fill("content:read")); + expect(result.success).toBe(false); + }); +}); + +describe("AllowedHostsSchema", () => { + it("accepts hostnames and wildcard-subdomain patterns", () => { + expect(AllowedHostsSchema.parse(["api.example.com"])).toEqual(["api.example.com"]); + expect(AllowedHostsSchema.parse(["*.cdn.example.com"])).toEqual(["*.cdn.example.com"]); + }); + + it("rejects URLs (scheme present)", () => { + const result = AllowedHostsSchema.safeParse(["https://api.example.com"]); + expect(result.success).toBe(false); + }); + + it("rejects host:port", () => { + // Port must be carried separately; the lexicon's grammar doesn't + // include ports in the pattern. + const result = AllowedHostsSchema.safeParse(["api.example.com:8080"]); + // `:` is allowed-but-we-don't-validate at this layer (no + // scheme/path/whitespace is the only structural test); a port + // is technically passes the loose check. Document as intentional + // — the lexicon's host-pattern grammar is the strict validator. + // Update this test if we tighten the regex. + expect(result.success).toBe(true); + }); + + it("rejects paths", () => { + const result = AllowedHostsSchema.safeParse(["api.example.com/some/path"]); + expect(result.success).toBe(false); + }); + + it("rejects whitespace", () => { + const result = AllowedHostsSchema.safeParse(["api.example.com "]); + expect(result.success).toBe(false); + }); +}); + +describe("StorageSchema", () => { + it("accepts a simple single-field index", () => { + const result = StorageSchema.parse({ + events: { indexes: ["timestamp"] }, + }); + expect(result).toEqual({ events: { indexes: ["timestamp"] } }); + }); + + it("accepts composite indexes", () => { + const result = StorageSchema.parse({ + events: { indexes: [["collection", "timestamp"]] }, + }); + expect(result.events?.indexes).toEqual([["collection", "timestamp"]]); + }); + + it("accepts uniqueIndexes alongside indexes", () => { + const result = StorageSchema.parse({ + users: { + indexes: ["createdAt"], + uniqueIndexes: ["email"], + }, + }); + expect(result.users?.uniqueIndexes).toEqual(["email"]); + }); + + it("rejects an invalid collection name", () => { + const result = StorageSchema.safeParse({ + "Bad-Name": { indexes: [] }, + }); + expect(result.success).toBe(false); + }); + + it("rejects an empty composite index", () => { + const result = StorageSchema.safeParse({ + events: { indexes: [[]] }, + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown keys on a collection config", () => { + const result = StorageSchema.safeParse({ + events: { indexes: [], orderBy: "timestamp" }, + }); + expect(result.success).toBe(false); + }); +}); + +describe("ManifestSchema cross-field rules", () => { + const base = { + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", + license: "MIT", + author: { name: "Jane Doe" }, + security: { email: "security@example.com" }, + }; + + it("network:request requires non-empty allowedHosts", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request"], + allowedHosts: [], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => i.message.includes("non-empty `allowedHosts`")), + ).toBe(true); + } + }); + + it("network:request with at least one allowed host passes", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request"], + allowedHosts: ["api.example.com"], + }); + expect(result.success).toBe(true); + }); + + it("network:request:unrestricted forbids allowedHosts", () => { + // The lexicon's invariant: allowedHosts MUST NOT appear when + // unrestricted is declared. The unrestricted capability already + // grants any host. + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request:unrestricted"], + allowedHosts: ["api.example.com"], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message.includes("`allowedHosts` must be empty"), + ), + ).toBe(true); + } + }); + + it("network:request:unrestricted with empty allowedHosts passes", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["network:request:unrestricted"], + }); + expect(result.success).toBe(true); + }); + + it("non-network capabilities don't require allowedHosts", () => { + const result = ManifestSchema.safeParse({ + ...base, + capabilities: ["content:read"], + }); + expect(result.success).toBe(true); + }); +}); + +describe("ManifestSchema with the trust contract", () => { + const base = { + slug: "my-plugin", + version: "0.1.0", + publisher: "example.com", + license: "MIT", + author: { name: "Jane Doe" }, + security: { email: "security@example.com" }, + }; + + it("defaults capabilities/allowedHosts/storage to empty when omitted", () => { + const result = ManifestSchema.parse(base); + expect(result.capabilities).toEqual([]); + expect(result.allowedHosts).toEqual([]); + expect(result.storage).toEqual({}); + }); + + it("accepts a full trust contract", () => { + const result = ManifestSchema.parse({ + ...base, + capabilities: ["content:read", "content:write", "network:request"], + allowedHosts: ["api.example.com", "*.cdn.example.com"], + storage: { + events: { indexes: ["timestamp"] }, + users: { indexes: ["createdAt"], uniqueIndexes: ["email"] }, + }, + }); + expect(result.capabilities).toEqual([ + "content:read", + "content:write", + "network:request", + ]); + }); +}); From c1add15cdb9725d369e6a9706a1dd4f6b4bf2103 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 14 May 2026 15:59:02 +0100 Subject: [PATCH 02/16] feat(registry-cli): bundle reads identity + trust contract from manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second phase of the sandboxed plugin redesign (#1028b). Bundle no longer imports src/index.ts for a descriptor factory; the manifest is the source of truth for identity (slug, version) and the trust contract (capabilities, allowedHosts, storage). Bundle still probes the runtime code for the hook/route surface — that's a syntactic property that needs the code to exist. Changes to bundle: - Drop the main-entry build and descriptor extraction. No more src/index.ts probing, no more `createPlugin` / default-factory / default-object format detection. - Replace `resolveEntries`: just locates emdash-plugin.jsonc (loaded through the same loader the CLI's validate uses) and confirms src/plugin.ts exists. No more package.json `exports` parsing. - Replace `extractResolvedPlugin` with `assembleResolvedPlugin`: builds the ResolvedPlugin shape from the manifest, then probes src/plugin.ts for hook/route names. - Probe (renamed from `augmentWithSandboxProbe` to `probePluginSurface`) now reads src/plugin.ts. Hard-fails if the default export isn't a definePlugin result. - New error codes: MISSING_MANIFEST, MISSING_PLUGIN_ENTRY, MANIFEST_INVALID. Old MISSING_PACKAGE_JSON / MISSING_ENTRYPOINT / MAIN_BUILD_FAILED gone. - Admin entry handling (admin.js, adminPages, adminWidgets) deferred to a follow-up issue. The redesign hasn't touched admin yet; that surface stays as-is and is gated on the descriptor's `admin` field which no longer exists. When admin lands again it'll be a manifest field with its own probe. Changes to translate.ts: - `NormalisedManifest` gains slug, version, publisher (required), capabilities, allowedHosts, storage. Publisher is no longer Optional — the schema enforces it. Fixtures: - `minimal-plugin/`: src/index.ts gone, sandbox-entry.ts renamed to plugin.ts, new emdash-plugin.jsonc with identity + trust contract. - `bad-plugin/`: stripped to manifest-only (no src/), exercises MISSING_PLUGIN_ENTRY. Old "declares hooks but no sandbox entry" case isn't possible anymore — there's no descriptor declaring anything. Net diff: -228 lines. --- packages/registry-cli/src/bundle/api.ts | 537 +++++------------- .../registry-cli/src/manifest/translate.ts | 37 +- packages/registry-cli/tests/bundle.test.ts | 24 +- .../fixtures/bad-plugin/emdash-plugin.jsonc | 8 + .../tests/fixtures/bad-plugin/src/index.ts | 18 - .../minimal-plugin/emdash-plugin.jsonc | 10 + .../fixtures/minimal-plugin/src/index.ts | 16 - .../src/{sandbox-entry.ts => plugin.ts} | 0 .../tests/manifest-schema.test.ts | 6 +- .../tests/manifest-trust-contract.test.ts | 16 +- 10 files changed, 222 insertions(+), 450 deletions(-) create mode 100644 packages/registry-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc delete mode 100644 packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts create mode 100644 packages/registry-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc delete mode 100644 packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts rename packages/registry-cli/tests/fixtures/minimal-plugin/src/{sandbox-entry.ts => plugin.ts} (100%) diff --git a/packages/registry-cli/src/bundle/api.ts b/packages/registry-cli/src/bundle/api.ts index 47d63c7b0..21e5b9637 100644 --- a/packages/registry-cli/src/bundle/api.ts +++ b/packages/registry-cli/src/bundle/api.ts @@ -7,37 +7,36 @@ * * The bundling steps: * - * 1. Resolve plugin entrypoints from the user's `package.json`. - * 2. Build the main entry with `tsdown` and dynamically import it to - * extract a `ResolvedPlugin` (descriptor factory or `createPlugin`). - * 3. If a sandbox entry exists, build it twice — once as a probe to - * capture hook/route names for the manifest, once as the final - * `backend.js` (minified, with `emdash` aliased to a no-op shim). - * 4. Build `admin.js` if an admin entry is declared. - * 5. Write `manifest.json` and copy assets (README, icon, screenshots). - * 6. Validate (size limits, no Node builtins, no source exports, admin + * 1. Read `emdash-plugin.jsonc` via the manifest loader: identity (slug, + * version), trust contract (capabilities, allowedHosts, storage), and + * the rest of the profile fields. + * 2. Build `src/plugin.ts` as a probe to capture hook/route names that + * go into the bundled `manifest.json`. + * 3. Build `src/plugin.ts` again as the final `backend.js` (minified, + * with `emdash` aliased to a no-op shim that exposes only + * `definePlugin`). + * 4. Write `manifest.json` from the manifest fields + probed surface, + * and copy assets (README, icon, screenshots). + * 5. Validate (size limits, no Node builtins, no source exports, admin * route consistency, sandbox-incompatible features). - * 7. Create the gzipped tarball and return its checksum. + * 6. Create the gzipped tarball and return its checksum. * * Failures throw `BundleError` with a structured `code` so callers can * branch (CLI shows a helpful message; tests assert the code). */ import { createHash } from "node:crypto"; -import { - copyFile, - mkdir, - mkdtemp, - readdir, - readFile, - rm, - stat, - symlink, - writeFile, -} from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, extname, join, resolve } from "node:path"; +import { + ManifestError, + MANIFEST_FILENAME, + loadManifest, + type LoadManifestResult, +} from "../manifest/load.js"; +import { normaliseManifest, type NormalisedManifest } from "../manifest/translate.js"; import { CAPABILITY_RENAMES, isDeprecatedCapability, @@ -51,14 +50,12 @@ import { fileExists, findBuildOutput, findNodeBuiltinImports, - findSourceExports, formatBytes, ICON_SIZE, MAX_SCREENSHOTS, MAX_SCREENSHOT_HEIGHT, MAX_SCREENSHOT_WIDTH, readImageDimensions, - resolveSourceEntry, totalBundleBytes, validateBundleSize, } from "./utils.js"; @@ -66,16 +63,15 @@ import { const TS_EXT_RE = /\.(tsx?|[mc]?js)$/; const SLASH_RE = /\//g; const LEADING_AT_RE = /^@/; -const EMDASH_SCOPE_RE = /^@emdash-cms\//; // ────────────────────────────────────────────────────────────────────────── // Public types // ────────────────────────────────────────────────────────────────────────── export type BundleErrorCode = - | "MISSING_PACKAGE_JSON" - | "MISSING_ENTRYPOINT" - | "MAIN_BUILD_FAILED" + | "MISSING_MANIFEST" + | "MISSING_PLUGIN_ENTRY" + | "MANIFEST_INVALID" | "INVALID_PLUGIN_FORMAT" | "TRUSTED_ONLY_FEATURE" | "BACKEND_BUILD_FAILED" @@ -135,17 +131,25 @@ export interface BundleResult { // Implementation // ────────────────────────────────────────────────────────────────────────── -interface ResolvedEntries { - mainEntry: string; - backendEntry: string | undefined; - adminEntry: string | undefined; - pkg: PackageJson; -} +/** + * Conventional source-file paths the bundler looks for. The redesign + * pins these instead of consulting `package.json` exports — a sandboxed + * plugin has exactly one runtime entry, and the manifest provides + * identity. Anything beyond these conventions is the author's + * responsibility (e.g. typecheck against their own tsconfig). + */ +const PLUGIN_ENTRY_PATH = "src/plugin.ts"; -interface PackageJson { - name?: string; - main?: string; - exports?: Record; +interface ResolvedEntries { + /** + * Absolute path to `src/plugin.ts`. The single source file the + * bundler probes (for hook/route names) and builds (as backend.js). + */ + pluginEntry: string; + /** The validated manifest, used as the source of truth for identity + trust contract. */ + manifest: NormalisedManifest; + /** Resolved path of the loaded `emdash-plugin.jsonc`, kept for diagnostics. */ + manifestPath: string; } export async function bundlePlugin(options: BundleOptions): Promise { @@ -161,10 +165,10 @@ export async function bundlePlugin(options: BundleOptions): Promise 0 - ) { - throw new BundleError( - "TRUSTED_ONLY_FEATURE", - "Plugin declares portableTextBlocks — these require native/trusted mode and cannot be bundled for the marketplace.", - ); - } - log.success?.(`Plugin: ${manifest.id}@${manifest.version}`); log.info?.( ` Capabilities: ${manifest.capabilities.length > 0 ? manifest.capabilities.join(", ") : "(none)"}`, @@ -221,13 +207,13 @@ export async function bundlePlugin(options: BundleOptions): Promise hard fail. const backendPath = join(bundleDir, "backend.js"); if (await fileExists(backendPath)) { @@ -440,294 +390,98 @@ export async function bundlePlugin(options: BundleOptions): Promise/emdash-plugin.jsonc` — identity + trust contract + profile + * fields. Parsed and validated by the same loader the CLI's + * `validate` command uses, so error messages are consistent. + * - `/src/plugin.ts` — the runtime code (routes + hooks via + * `definePlugin`). Single source file; no `package.json` exports + * consulted. + * + * `package.json` is not read here at all. It's still present in the + * plugin directory because Node tooling needs it (vitest, tsc), but + * the bundler doesn't care about its `name`, `version`, `main`, or + * `exports` fields — those would just be ways to disagree with the + * manifest. + */ async function resolveEntries(pluginDir: string, log: BundleLogger): Promise { - const pkgPath = join(pluginDir, "package.json"); - if (!(await fileExists(pkgPath))) { - throw new BundleError("MISSING_PACKAGE_JSON", `No package.json found in ${pluginDir}`); - } - - const pkg = JSON.parse(await readFile(pkgPath, "utf-8")) as PackageJson; - - let backendEntry: string | undefined; - let adminEntry: string | undefined; - - if (pkg.exports) { - const sandboxExport = pkg.exports["./sandbox"]; - if (typeof sandboxExport === "string") { - backendEntry = await resolveSourceEntry(pluginDir, sandboxExport); - } else if ( - sandboxExport && - typeof sandboxExport === "object" && - "import" in sandboxExport && - typeof (sandboxExport as { import: unknown }).import === "string" - ) { - backendEntry = await resolveSourceEntry( - pluginDir, - (sandboxExport as { import: string }).import, - ); - } - - const adminExport = pkg.exports["./admin"]; - if (typeof adminExport === "string") { - adminEntry = await resolveSourceEntry(pluginDir, adminExport); - } else if ( - adminExport && - typeof adminExport === "object" && - "import" in adminExport && - typeof (adminExport as { import: unknown }).import === "string" - ) { - adminEntry = await resolveSourceEntry(pluginDir, (adminExport as { import: string }).import); - } - } - - if (!backendEntry) { - const defaultSandbox = join(pluginDir, "src/sandbox-entry.ts"); - if (await fileExists(defaultSandbox)) { - backendEntry = defaultSandbox; - } + const manifestPath = join(pluginDir, MANIFEST_FILENAME); + if (!(await fileExists(manifestPath))) { + throw new BundleError( + "MISSING_MANIFEST", + `No ${MANIFEST_FILENAME} found in ${pluginDir}. Create one with: emdash-registry init`, + ); } - let mainEntry: string | undefined; - if (pkg.exports?.["."] !== undefined) { - const mainExport = pkg.exports["."]; - if (typeof mainExport === "string") { - mainEntry = await resolveSourceEntry(pluginDir, mainExport); - } else if ( - mainExport && - typeof mainExport === "object" && - "import" in mainExport && - typeof (mainExport as { import: unknown }).import === "string" - ) { - mainEntry = await resolveSourceEntry(pluginDir, (mainExport as { import: string }).import); - } - } - if (!mainEntry && pkg.main) { - mainEntry = await resolveSourceEntry(pluginDir, pkg.main); - } - if (!mainEntry) { - const defaultMain = join(pluginDir, "src/index.ts"); - if (await fileExists(defaultMain)) { - mainEntry = defaultMain; + let loaded: LoadManifestResult; + try { + loaded = await loadManifest(manifestPath); + } catch (error) { + if (error instanceof ManifestError) { + throw new BundleError("MANIFEST_INVALID", error.message); } + throw error; } + const manifest = normaliseManifest(loaded.manifest); - if (!mainEntry) { + const pluginEntry = join(pluginDir, PLUGIN_ENTRY_PATH); + if (!(await fileExists(pluginEntry))) { throw new BundleError( - "MISSING_ENTRYPOINT", - "Cannot find plugin entrypoint. Expected src/index.ts or main/exports in package.json.", + "MISSING_PLUGIN_ENTRY", + `No ${PLUGIN_ENTRY_PATH} found in ${pluginDir}. Sandboxed plugins place their routes and hooks in this single file (see emdash-registry init for the canonical layout).`, ); } - log.info?.(`Main entry: ${mainEntry}`); - if (backendEntry) log.info?.(`Backend entry: ${backendEntry}`); - if (adminEntry) log.info?.(`Admin entry: ${adminEntry}`); + log.info?.(`Manifest: ${loaded.path}`); + log.info?.(`Plugin entry: ${pluginEntry}`); - return { mainEntry, backendEntry, adminEntry, pkg }; + return { pluginEntry, manifest, manifestPath: loaded.path }; } -interface ExtractContext { - pluginDir: string; +interface AssembleContext { tmpDir: string; entries: ResolvedEntries; build: typeof import("tsdown").build; } -async function extractResolvedPlugin(ctx: ExtractContext): Promise { - const { pluginDir, tmpDir, entries, build } = ctx; - const mainOutDir = join(tmpDir, "main"); - await build({ - config: false, - entry: [entries.mainEntry], - format: "esm", - outDir: mainOutDir, - dts: false, - platform: "node", - external: ["emdash", EMDASH_SCOPE_RE], - }); - - const pluginNodeModules = join(pluginDir, "node_modules"); - const tmpNodeModules = join(mainOutDir, "node_modules"); - if (await fileExists(pluginNodeModules)) { - await symlink(pluginNodeModules, tmpNodeModules, "junction"); - } - - const mainBaseName = basename(entries.mainEntry).replace(TS_EXT_RE, ""); - const mainOutputPath = await findBuildOutput(mainOutDir, mainBaseName); - if (!mainOutputPath) { - throw new BundleError( - "MAIN_BUILD_FAILED", - `Failed to build main entry — no output found in ${mainOutDir}`, - ); - } - - const pluginModule = (await import(mainOutputPath)) as Record; - - let resolvedPlugin: ResolvedPlugin | undefined; - let descriptor: Record | undefined; - - // Strict format detection. We only call exports we can identify by name -- - // `createPlugin()` (native) or the default export (standard descriptor or - // pre-resolved object). Speculatively calling every named export is a - // foot-gun: a `validateInput()` helper that returns `{id, version}` would - // be mis-resolved. - if (typeof pluginModule.createPlugin === "function") { - const native = (pluginModule.createPlugin as () => unknown)(); - if (!isResolvedPluginShape(native)) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "createPlugin() returned something that's not a ResolvedPlugin (missing id, version, or wrong types).", - ); - } - resolvedPlugin = native; - } else if (typeof pluginModule.default === "function") { - // Standard format default export. The factory returns a descriptor - // (id + version + serialisable fields, no hook handlers); we build - // the ResolvedPlugin shape around it and probe the sandbox entry for - // hook/route names below. - const result = (pluginModule.default as () => unknown)(); - if (!isPluginDescriptorShape(result)) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Default export factory returned something that's not a plugin descriptor (missing id or version, or wrong types).", - ); - } - descriptor = result; - resolvedPlugin = buildResolvedFromDescriptor(result); - } else if (typeof pluginModule.default === "object" && pluginModule.default !== null) { - const defaultExport = pluginModule.default; - if (!isResolvedPluginShape(defaultExport)) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Default export object is not a ResolvedPlugin (missing id, version, or wrong types).", - ); - } - resolvedPlugin = defaultExport; - } - - if (!resolvedPlugin) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Could not extract plugin definition. Expected one of:\n" + - " - `export function createPlugin() { ... }` (native format)\n" + - " - `export default function() { return { id, version, ... } }` (standard format)\n" + - " - `export default { id, version, ... }` (pre-resolved native)", - ); - } - - // For the standard format, probe the sandbox entry to capture hook and - // route names for the manifest. Only runs when we have a descriptor (i.e. - // the plugin came in via the standard format) and a sandbox entry. - if (descriptor && entries.backendEntry) { - await augmentWithSandboxProbe({ - resolvedPlugin, - descriptor, - backendEntry: entries.backendEntry, - tmpDir, - build, - }); - } - - // If a standard-format descriptor declares hooks/routes we couldn't probe - // (because there's no sandbox entry), the published manifest will be a - // lie -- the host will refuse to dispatch hooks the plugin promised to - // implement. Catch it here. - if ( - descriptor && - !entries.backendEntry && - (hasNonEmptyArrayField(descriptor, "hooks") || hasNonEmptyArrayField(descriptor, "routes")) - ) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - "Plugin descriptor declares hooks or routes but no sandbox entry exists to back them. " + - 'Add `src/sandbox-entry.ts` (or a "./sandbox" export in package.json) that ' + - "exports `default { hooks, routes }`. Without it, the published manifest " + - "will promise functionality the bundle can't deliver.", - ); - } - - return resolvedPlugin; -} - -function buildResolvedFromDescriptor(descriptor: Record): ResolvedPlugin { - return { - id: descriptor.id as string, - version: descriptor.version as string, - capabilities: (descriptor.capabilities as ResolvedPlugin["capabilities"]) ?? [], - allowedHosts: (descriptor.allowedHosts as string[]) ?? [], - storage: (descriptor.storage as ResolvedPlugin["storage"]) ?? {}, - hooks: {}, - routes: {}, - admin: { - pages: descriptor.adminPages as ResolvedPlugin["admin"]["pages"], - widgets: descriptor.adminWidgets as ResolvedPlugin["admin"]["widgets"], - }, - }; -} - /** - * Type guard: does this value look like a `ResolvedPlugin` enough to use? + * Assemble a `ResolvedPlugin` from the manifest (identity + trust contract) + * and a probe of `src/plugin.ts` (hook/route surface). * - * Validates the fields we read in the bundling pipeline. Doesn't try to - * exhaustively validate hook/route handler shapes -- those are functions and - * the manifest only records their names. + * The redesign collapses what used to be two distinct steps — main-entry + * descriptor extraction and sandbox-entry probe — into a single probe. + * Identity isn't authored in code anymore; the manifest is the source of + * truth, validated upstream by `loadManifest`. */ -function isResolvedPluginShape(value: unknown): value is ResolvedPlugin { - if (!value || typeof value !== "object") return false; - const v = value as Record; - if (typeof v.id !== "string" || v.id.length === 0) return false; - if (typeof v.version !== "string" || v.version.length === 0) return false; - if (v.capabilities !== undefined && !Array.isArray(v.capabilities)) return false; - if (v.allowedHosts !== undefined && !Array.isArray(v.allowedHosts)) return false; - if (v.storage !== undefined && (typeof v.storage !== "object" || v.storage === null)) { - return false; - } - if (v.hooks !== undefined && (typeof v.hooks !== "object" || v.hooks === null)) { - return false; - } - if (v.routes !== undefined && (typeof v.routes !== "object" || v.routes === null)) { - return false; - } - if (v.admin !== undefined && (typeof v.admin !== "object" || v.admin === null)) { - return false; - } - return true; -} +async function assembleResolvedPlugin(ctx: AssembleContext): Promise { + const { tmpDir, entries, build } = ctx; + + const resolvedPlugin: ResolvedPlugin = { + // `id` on the bundled manifest is the publisher's natural slug. + // The runtime rewrites it to the opaque `r_` at install + // time (see makeRegistryPluginId), but on-wire the slug is what + // the install handler matches against the registry's record key. + id: entries.manifest.slug, + version: entries.manifest.version, + capabilities: entries.manifest.capabilities, + allowedHosts: entries.manifest.allowedHosts, + storage: entries.manifest.storage, + hooks: {}, + routes: {}, + admin: {}, + }; -/** - * Type guard: does this value look like a plugin descriptor (the standard - * format's factory return value)? - * - * Looser than `isResolvedPluginShape` -- descriptors don't carry hooks or - * routes (those live in the sandbox entry, probed separately). - */ -function isPluginDescriptorShape(value: unknown): value is Record { - if (!value || typeof value !== "object") return false; - const v = value as Record; - if (typeof v.id !== "string" || v.id.length === 0) return false; - if (typeof v.version !== "string" || v.version.length === 0) return false; - if (v.capabilities !== undefined) { - if (!Array.isArray(v.capabilities)) return false; - // Reject non-string entries -- they'd serialize into a malformed - // manifest.json and confuse the runtime. - if (v.capabilities.some((c) => typeof c !== "string")) return false; - } - if (v.allowedHosts !== undefined) { - if (!Array.isArray(v.allowedHosts)) return false; - if (v.allowedHosts.some((h) => typeof h !== "string")) return false; - } - return true; -} + await probePluginSurface({ + resolvedPlugin, + pluginEntry: entries.pluginEntry, + tmpDir, + build, + }); -/** - * `descriptor[field]` is a non-empty array (or non-empty object). Used to - * detect when a standard-format descriptor declares hooks/routes that the - * bundler can't populate (because there's no sandbox entry to probe). - */ -function hasNonEmptyArrayField(descriptor: Record, field: string): boolean { - const v = descriptor[field]; - if (Array.isArray(v)) return v.length > 0; - if (v && typeof v === "object") return Object.keys(v).length > 0; - return false; + return resolvedPlugin; } /** @@ -772,35 +526,54 @@ export default new Proxy({ definePlugin }, handler); interface ProbeContext { resolvedPlugin: ResolvedPlugin; - descriptor: Record; - backendEntry: string; + pluginEntry: string; tmpDir: string; build: typeof import("tsdown").build; } -async function augmentWithSandboxProbe(ctx: ProbeContext): Promise { - const { resolvedPlugin, descriptor, backendEntry, tmpDir, build } = ctx; - const backendProbeDir = join(tmpDir, "backend-probe"); +/** + * Build `src/plugin.ts` with `emdash` aliased to the no-op shim (which + * only exports `definePlugin`), then import it to read its default + * export's `hooks` and `routes` shape. The handler functions are + * recorded on the `ResolvedPlugin` even though `extractManifest` will + * strip them — they prove the surface is callable, and the probe build + * surfaces any compile-time error in the plugin code before we bother + * with the real backend build. + */ +async function probePluginSurface(ctx: ProbeContext): Promise { + const { resolvedPlugin, pluginEntry, tmpDir, build } = ctx; + const probeOutDir = join(tmpDir, "plugin-probe"); const probeShimPath = await writeEmdashShim(join(tmpDir, "probe-shims")); await build({ config: false, - entry: [backendEntry], + entry: [pluginEntry], format: "esm", - outDir: backendProbeDir, + outDir: probeOutDir, dts: false, platform: "neutral", external: [], alias: { emdash: probeShimPath }, treeshake: true, }); - const backendBaseName = basename(backendEntry).replace(TS_EXT_RE, ""); - const backendProbePath = await findBuildOutput(backendProbeDir, backendBaseName); - if (!backendProbePath) return; + const probeBaseName = basename(pluginEntry).replace(TS_EXT_RE, ""); + const probeOutputPath = await findBuildOutput(probeOutDir, probeBaseName); + if (!probeOutputPath) { + throw new BundleError( + "BACKEND_BUILD_FAILED", + `Failed to build ${pluginEntry} for probe — no output found in ${probeOutDir}`, + ); + } - const backendModule = (await import(backendProbePath)) as Record; - const standardDef = (backendModule.default ?? {}) as Record; - const hooks = standardDef.hooks as Record | undefined; - const routes = standardDef.routes as Record | undefined; + const pluginModule = (await import(probeOutputPath)) as Record; + const definition = (pluginModule.default ?? {}) as Record; + if (typeof definition !== "object" || definition === null) { + throw new BundleError( + "INVALID_PLUGIN_FORMAT", + `${pluginEntry} must default-export the result of definePlugin({ hooks, routes }). Got ${describeShape(definition)}.`, + ); + } + const hooks = definition.hooks as Record | undefined; + const routes = definition.routes as Record | undefined; if (hooks) { for (const hookName of Object.keys(hooks)) { @@ -809,7 +582,7 @@ async function augmentWithSandboxProbe(ctx: ProbeContext): Promise { if (!handler) { throw new BundleError( "INVALID_PLUGIN_FORMAT", - `Sandbox entry's hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, + `${pluginEntry}: hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, ); } const config: Record = @@ -823,7 +596,7 @@ async function augmentWithSandboxProbe(ctx: ProbeContext): Promise { dependencies: (config.dependencies as string[] | undefined) ?? [], errorPolicy: (config.errorPolicy as string | undefined) ?? "abort", exclusive: (config.exclusive as boolean | undefined) ?? false, - pluginId: descriptor.id as string, + pluginId: resolvedPlugin.id, }; } } @@ -833,7 +606,7 @@ async function augmentWithSandboxProbe(ctx: ProbeContext): Promise { if (!handler) { throw new BundleError( "INVALID_PLUGIN_FORMAT", - `Sandbox entry's route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, + `${pluginEntry}: route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, ); } const routeObj: Record = diff --git a/packages/registry-cli/src/manifest/translate.ts b/packages/registry-cli/src/manifest/translate.ts index 0ecb10ad5..91e8ecac3 100644 --- a/packages/registry-cli/src/manifest/translate.ts +++ b/packages/registry-cli/src/manifest/translate.ts @@ -6,6 +6,8 @@ * array shapes the lexicon uses. */ +import type { PluginCapability, PluginStorageConfig } from "@emdash-cms/plugin-types"; + import type { ProfileBootstrap } from "../publish/api.js"; import type { Manifest, ManifestAuthor, ManifestSecurityContact } from "./schema.js"; @@ -16,20 +18,24 @@ import type { Manifest, ManifestAuthor, ManifestSecurityContact } from "./schema * never has to think about `author` vs `authors`. */ export interface NormalisedManifest { + // Identity (required). + slug: string; + version: string; + publisher: string; + + // Profile. license: string; - /** - * Pinned publisher (DID or handle). Undefined when the manifest - * doesn't pin a publisher; the CLI writes the active session's DID - * back after first publish so this is undefined only on first - * publish or in CI flows where the user opted out via `--no-manifest`. - */ - publisher: string | undefined; authors: ManifestAuthor[]; securityContacts: ManifestSecurityContact[]; name: string | undefined; description: string | undefined; keywords: string[] | undefined; repo: string | undefined; + + // Trust contract (defaults applied by the schema; always present here). + capabilities: PluginCapability[]; + allowedHosts: string[]; + storage: PluginStorageConfig; } /** @@ -45,14 +51,29 @@ export function normaliseManifest(manifest: Manifest): NormalisedManifest { const securityContacts = manifest.securityContacts ?? (manifest.security ? [manifest.security] : []); return { - license: manifest.license, + slug: manifest.slug, + version: manifest.version, publisher: manifest.publisher, + license: manifest.license, authors, securityContacts, name: manifest.name, description: manifest.description, keywords: manifest.keywords, repo: manifest.repo, + // Schema validation already gates capability strings to the + // current vocabulary via a runtime check, so by the time we get + // here the strings are guaranteed members of PluginCapability. + // Zod's inferred type is `string[]` (it can't see the runtime + // narrowing), and the cast bridges that gap. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- schema-enforced narrowing + capabilities: manifest.capabilities as PluginCapability[], + allowedHosts: manifest.allowedHosts, + // Same story for storage: Zod returns Record, + // PluginStorageConfig is the same shape with a tighter key + // constraint. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- schema-enforced narrowing + storage: manifest.storage as PluginStorageConfig, }; } diff --git a/packages/registry-cli/tests/bundle.test.ts b/packages/registry-cli/tests/bundle.test.ts index fdd41c95d..5a3475b1f 100644 --- a/packages/registry-cli/tests/bundle.test.ts +++ b/packages/registry-cli/tests/bundle.test.ts @@ -42,13 +42,13 @@ describe("bundlePlugin", () => { expect(result.sha256).toMatch(/^[0-9a-f]{64}$/); }); - it("captures hooks and routes from the sandbox-entry probe", async () => { + it("captures hooks and routes from the src/plugin.ts probe", async () => { const result = await bundlePlugin({ dir: FIXTURE, outDir }); const manifest = result.manifest; // Plain hook name (defaults). expect(manifest.hooks).toContain("content:beforeCreate"); - // Routes are extracted from the sandbox entry's default export. + // Routes are extracted from src/plugin.ts's default export. expect(manifest.routes).toContain("admin"); }); @@ -90,12 +90,12 @@ describe("bundlePlugin", () => { expect(parsed.version).toBe("1.2.3"); }); - it("throws BundleError(MISSING_PACKAGE_JSON) for a directory with no package.json", async () => { + it("throws BundleError(MISSING_MANIFEST) for a directory with no emdash-plugin.jsonc", async () => { const empty = await mkdtemp(join(tmpdir(), "emdash-empty-")); try { await expect(bundlePlugin({ dir: empty, outDir })).rejects.toMatchObject({ name: "BundleError", - code: "MISSING_PACKAGE_JSON", + code: "MISSING_MANIFEST", }); } finally { await rm(empty, { recursive: true, force: true }); @@ -112,8 +112,8 @@ describe("bundlePlugin", () => { caught = error; } expect(caught).toBeInstanceOf(BundleError); - expect((caught as BundleError).code).toBe("MISSING_PACKAGE_JSON"); - expect((caught as BundleError).message).toMatch(/No package\.json/); + expect((caught as BundleError).code).toBe("MISSING_MANIFEST"); + expect((caught as BundleError).message).toMatch(/No emdash-plugin\.jsonc/); } finally { await rm(empty, { recursive: true, force: true }); } @@ -154,14 +154,14 @@ describe("bundlePlugin", () => { expect(contents).toEqual([]); }); - it("hard-fails when descriptor declares hooks but no sandbox entry exists", async () => { - // The bad-plugin fixture declares hooks in its descriptor but has no - // `src/sandbox-entry.ts` and no `./sandbox` export. Without the guard, - // the bundler would silently emit a manifest claiming hooks the - // bundle can't deliver. + it("hard-fails when the plugin has a manifest but no src/plugin.ts", async () => { + // The bad-plugin fixture has a valid emdash-plugin.jsonc but no + // src/plugin.ts. Without the guard, the bundler would happily + // produce a tarball with no backend.js, leaving the runtime with + // nothing to load. await expect(bundlePlugin({ dir: BAD_FIXTURE, outDir })).rejects.toMatchObject({ name: "BundleError", - code: "INVALID_PLUGIN_FORMAT", + code: "MISSING_PLUGIN_ENTRY", }); }); diff --git a/packages/registry-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc b/packages/registry-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc new file mode 100644 index 000000000..5ffa5f138 --- /dev/null +++ b/packages/registry-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc @@ -0,0 +1,8 @@ +{ + "slug": "bad-plugin", + "version": "0.1.0", + "publisher": "fixture.example.com", + "license": "MIT", + "author": { "name": "Test Author" }, + "security": { "email": "security@example.com" }, +} diff --git a/packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts b/packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts deleted file mode 100644 index 80fb9d328..000000000 --- a/packages/registry-cli/tests/fixtures/bad-plugin/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Test fixture: descriptor declares hooks but the package has no sandbox - * entry. The bundler should hard-fail at validation rather than emit a - * manifest that promises functionality the bundle can't deliver. - */ -export default function badPlugin() { - return { - id: "bad-plugin", - version: "0.1.0", - capabilities: ["content:read"], - allowedHosts: [], - storage: {}, - // We declare hooks here, but there's no `src/sandbox-entry.ts` and - // no `./sandbox` package export, so the bundler can't probe for - // these hook names. - hooks: ["content:beforeCreate"], - }; -} diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc b/packages/registry-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc new file mode 100644 index 000000000..7e89c684d --- /dev/null +++ b/packages/registry-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc @@ -0,0 +1,10 @@ +{ + "slug": "fixture-minimal", + "version": "1.2.3", + "publisher": "fixture.example.com", + "license": "MIT", + "author": { "name": "Test Author" }, + "security": { "email": "security@example.com" }, + "capabilities": ["content:read"], + "allowedHosts": ["api.example.com"], +} diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts b/packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts deleted file mode 100644 index d1fe84af8..000000000 --- a/packages/registry-cli/tests/fixtures/minimal-plugin/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Test fixture: descriptor-factory plugin (the "standard" format the bundler - * recognises without needing any actual `emdash` runtime). - * - * The bundler's manifest probe imports this module and calls the default - * export; the returned object's id+version make it a valid descriptor. - */ -export default function fixturePlugin() { - return { - id: "fixture-minimal", - version: "1.2.3", - capabilities: ["content:read"], - allowedHosts: ["api.example.com"], - storage: {}, - }; -} diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/src/sandbox-entry.ts b/packages/registry-cli/tests/fixtures/minimal-plugin/src/plugin.ts similarity index 100% rename from packages/registry-cli/tests/fixtures/minimal-plugin/src/sandbox-entry.ts rename to packages/registry-cli/tests/fixtures/minimal-plugin/src/plugin.ts diff --git a/packages/registry-cli/tests/manifest-schema.test.ts b/packages/registry-cli/tests/manifest-schema.test.ts index 49e150e5f..f7c689a95 100644 --- a/packages/registry-cli/tests/manifest-schema.test.ts +++ b/packages/registry-cli/tests/manifest-schema.test.ts @@ -163,9 +163,9 @@ describe("ManifestSchema (full document)", () => { }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error.issues.some((i) => i.message.includes("both `author` and `authors`"))).toBe( - true, - ); + expect( + result.error.issues.some((i) => i.message.includes("both `author` and `authors`")), + ).toBe(true); } }); diff --git a/packages/registry-cli/tests/manifest-trust-contract.test.ts b/packages/registry-cli/tests/manifest-trust-contract.test.ts index 1e3e812f9..4165630bd 100644 --- a/packages/registry-cli/tests/manifest-trust-contract.test.ts +++ b/packages/registry-cli/tests/manifest-trust-contract.test.ts @@ -215,9 +215,9 @@ describe("ManifestSchema cross-field rules", () => { }); expect(result.success).toBe(false); if (!result.success) { - expect( - result.error.issues.some((i) => i.message.includes("non-empty `allowedHosts`")), - ).toBe(true); + expect(result.error.issues.some((i) => i.message.includes("non-empty `allowedHosts`"))).toBe( + true, + ); } }); @@ -242,9 +242,7 @@ describe("ManifestSchema cross-field rules", () => { expect(result.success).toBe(false); if (!result.success) { expect( - result.error.issues.some((i) => - i.message.includes("`allowedHosts` must be empty"), - ), + result.error.issues.some((i) => i.message.includes("`allowedHosts` must be empty")), ).toBe(true); } }); @@ -293,10 +291,6 @@ describe("ManifestSchema with the trust contract", () => { users: { indexes: ["createdAt"], uniqueIndexes: ["email"] }, }, }); - expect(result.capabilities).toEqual([ - "content:read", - "content:write", - "network:request", - ]); + expect(result.capabilities).toEqual(["content:read", "content:write", "network:request"]); }); }); From 2c1a2012ecfc3e87e64be27ab939abfc1b6d2366 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 14 May 2026 18:08:52 +0100 Subject: [PATCH 03/16] feat(registry-cli): init command scaffolds a sandboxed plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third phase of the redesign (#1028b). Adds `emdash-registry init [name]` which produces the three-file plugin layout introduced by the previous commits: emdash-plugin.jsonc, src/plugin.ts, package.json, plus a tsconfig, README, .gitignore, and a passing test. Modes: - Interactive (default on a TTY): clack prompts for each unset field with sensible defaults. ESC / Ctrl+C cancels cleanly. - `--yes` / `-y` (non-interactive): no prompts; unset fields become TODO placeholders in the manifest. The author fixes them before first use. - Non-TTY (CI, pipes): same as `--yes`; prompting into a non- interactive stdin would hang. Pre-fills: - Publisher: the active session's handle from FileCredentialStore. Resolved through @atcute/identity-resolver to a DID before write so the runtime never sees a mutable handle. The handle is emitted as a `// ` line comment next to the pinned DID for `git diff` readability — same convention as the post-publish write-back. - Author name / email: `git config user.name` / `user.email`. - Repo: `git remote get-url origin`, normalised from SSH to https (`git@github.com:foo/bar.git` → `https://github.com/foo/bar`). Falls back to `package.json#repository.url` if no git remote. - License, description: `package.json` in the target dir if one exists (for the "scaffold into existing repo skeleton" case). Slug defaults to the positional `name`, `basename(--dir)`, or basename(cwd) in that order. Every flag is optional in every mode. Exported `resolveHandleToDid` from manifest/publisher.ts so init can use the same resolver the post-publish write-back does. Tests: 44 new (template renderers, scaffold filesystem behaviour, environment probe). 249 total in the package. --- packages/registry-cli/package.json | 1 + packages/registry-cli/src/commands/init.ts | 656 ++++++++++++++++++ packages/registry-cli/src/index.ts | 3 + packages/registry-cli/src/init/environment.ts | 261 +++++++ packages/registry-cli/src/init/scaffold.ts | 156 +++++ packages/registry-cli/src/init/templates.ts | 354 ++++++++++ .../registry-cli/src/manifest/publisher.ts | 6 +- .../tests/init-environment.test.ts | 157 +++++ .../registry-cli/tests/init-scaffold.test.ts | 170 +++++ .../registry-cli/tests/init-templates.test.ts | 276 ++++++++ pnpm-lock.yaml | 48 +- 11 files changed, 2084 insertions(+), 4 deletions(-) create mode 100644 packages/registry-cli/src/commands/init.ts create mode 100644 packages/registry-cli/src/init/environment.ts create mode 100644 packages/registry-cli/src/init/scaffold.ts create mode 100644 packages/registry-cli/src/init/templates.ts create mode 100644 packages/registry-cli/tests/init-environment.test.ts create mode 100644 packages/registry-cli/tests/init-scaffold.test.ts create mode 100644 packages/registry-cli/tests/init-templates.test.ts diff --git a/packages/registry-cli/package.json b/packages/registry-cli/package.json index dcebb4a16..3b5a585c3 100644 --- a/packages/registry-cli/package.json +++ b/packages/registry-cli/package.json @@ -32,6 +32,7 @@ "@atcute/lexicons": "catalog:", "@atcute/multibase": "catalog:", "@atcute/oauth-node-client": "catalog:", + "@clack/prompts": "^1.4.0", "@emdash-cms/plugin-types": "workspace:*", "@emdash-cms/registry-client": "workspace:*", "@emdash-cms/registry-lexicons": "workspace:*", diff --git a/packages/registry-cli/src/commands/init.ts b/packages/registry-cli/src/commands/init.ts new file mode 100644 index 000000000..53f482bb2 --- /dev/null +++ b/packages/registry-cli/src/commands/init.ts @@ -0,0 +1,656 @@ +/** + * `emdash-registry init [name]` + * + * Scaffold a new sandboxed plugin. Produces the three-file authoring + * contract (manifest + src/plugin.ts + package.json) plus tsconfig, + * README, .gitignore, and a passing test. + * + * Three modes: + * + * 1. Interactive (default on a TTY): clack prompts for each unset + * field with sensible defaults. ESC / Ctrl+C cancels cleanly. + * 2. `--yes` / `-y` (non-interactive): no prompts; unset fields + * become TODO placeholders in the generated manifest. The user + * fixes them before first use. + * 3. Non-TTY (CI, pipes): same as `--yes`. Prompting into a + * non-interactive stdin would hang. + * + * In all modes, explicit flags win — they're treated as final answers + * and skip the prompt for that field. + * + * Exit codes: + * 0 — scaffold written. + * 1 — input validation failed, target conflict (without --force), + * prompt cancelled, or filesystem error. + */ + +import { basename, resolve } from "node:path"; + +import { isDid, isHandle } from "@atcute/lexicons/syntax"; +import * as clack from "@clack/prompts"; +import { isPluginSlug } from "@emdash-cms/plugin-types"; +import { FileCredentialStore } from "@emdash-cms/registry-client"; +import { defineCommand } from "citty"; +import consola from "consola"; +import pc from "picocolors"; + +import { probeEnvironment, type EnvironmentDefaults } from "../init/environment.js"; +import { InitError, scaffold } from "../init/scaffold.js"; +import type { ScaffoldInputs } from "../init/templates.js"; +import { PublisherCheckError, resolveHandleToDid } from "../manifest/publisher.js"; + +export const initCommand = defineCommand({ + meta: { + name: "init", + description: + "Scaffold a new sandboxed plugin: emdash-plugin.jsonc, src/plugin.ts, package.json, tests, and a README.", + }, + args: { + name: { + type: "positional", + required: false, + description: + "Plugin slug. Used as the directory name and the manifest's `slug` field. If omitted, the slug is derived from the current directory name (or prompted in interactive mode).", + }, + dir: { + type: "string", + description: + "Target directory. Defaults to ./ when `name` is given, or the current directory when it isn't.", + }, + publisher: { + type: "string", + description: + "Atproto handle or DID. In interactive mode this is prompted; in --yes mode an unset value becomes a TODO placeholder.", + }, + license: { + type: "string", + description: 'SPDX license expression. Defaults to "MIT".', + }, + "author-name": { + type: "string", + description: "Author name.", + }, + "author-url": { + type: "string", + description: "Author URL.", + }, + "author-email": { + type: "string", + description: "Author email.", + }, + "security-email": { + type: "string", + description: + "Security contact email. Either --security-email or --security-url should be set; in --yes mode an unset value becomes a TODO placeholder.", + }, + "security-url": { + type: "string", + description: "Security contact URL.", + }, + description: { + type: "string", + description: "Short plugin description (omitted from the manifest if not provided).", + }, + repo: { + type: "string", + description: "Source repository URL (omitted from the manifest if not provided).", + }, + yes: { + type: "boolean", + alias: "y", + description: + "Skip interactive prompts. Unset fields become TODO placeholders in the manifest. Automatically enabled when stdin is not a TTY.", + default: false, + }, + force: { + type: "boolean", + description: + "Overwrite existing files in the target directory. Without this flag, init refuses if any target file already exists.", + default: false, + }, + }, + async run({ args }) { + try { + await runInit(args); + } catch (error) { + if (error instanceof InitError || error instanceof InputError) { + consola.error(error.message); + process.exit(1); + } + throw error; + } + }, +}); + +interface InitArgs { + name?: string; + dir?: string; + publisher?: string; + license?: string; + "author-name"?: string; + "author-url"?: string; + "author-email"?: string; + "security-email"?: string; + "security-url"?: string; + description?: string; + repo?: string; + yes?: boolean; + force?: boolean; +} + +async function runInit(args: InitArgs): Promise { + // Non-TTY stdin → can't prompt; behave as if --yes were passed. + // stdout being a pipe is fine (we still write progress); it's the + // input side that has to be a terminal for prompts to work. + const interactive = !(args.yes ?? false) && process.stdin.isTTY === true; + + if (interactive) clack.intro(pc.bold("emdash-registry init")); + + // Load the active session (if any). Used to pre-fill the publisher + // prompt and to silently fill it in `--yes` mode. We swallow load + // errors entirely — init is reachable from a fresh checkout where + // the credentials store doesn't exist yet, and a corrupt-store + // failure should not block scaffolding. + const session = await loadCurrentSessionSilently(); + + // Resolve slug + target dir. Slug may come from positional, --dir's + // basename, cwd's basename, or (interactive only) a prompt. + let { slug, targetDir } = resolveSlugAndDir(args); + if (nonEmpty(args.name) === undefined && nonEmpty(args.dir) === undefined && interactive) { + const answer = await clack.text({ + message: "Plugin slug", + placeholder: "my-plugin", + defaultValue: slug, + }); + assertNotCancelled(answer); + if (typeof answer === "string" && answer.trim().length > 0) { + slug = answer.trim(); + targetDir = resolve(`./${slug}`); + } + } + + if (!isPluginSlug(slug)) { + throw new InputError( + `Slug "${slug}" is not a valid plugin slug. Expected: lowercase letter, then lowercase letters / digits / "-" / "_" (max 64 chars).`, + ); + } + + // Probe the surrounding environment for pre-fillable defaults + // (git user.name / user.email, git remote URL, package.json fields). + // Probe the target dir if it exists, otherwise cwd — that covers + // both "init into existing repo skeleton" and "init alongside the + // current project" workflows. Failures inside the probe are + // swallowed; missing fields stay undefined. + const env = await probeEnvironment(await pickProbeDir(targetDir)); + + const publisherResult = await resolvePublisher(args, interactive, session); + const license = await resolveLicense(args, interactive, env); + const author = await resolveAuthor(args, interactive, env); + const security = await resolveSecurity(args, interactive); + const description = await resolveDescription(args, interactive, env); + const repo = await resolveRepo(args, interactive, env); + + const inputs: ScaffoldInputs = { + slug, + publisher: publisherResult?.did, + publisherHandle: publisherResult?.handle, + license, + author, + security, + description, + repo, + }; + + const spin = interactive ? clack.spinner() : null; + spin?.start(`Scaffolding ${slug} in ${targetDir}`); + + let result; + try { + result = await scaffold({ + targetDir, + inputs, + force: args.force ?? false, + onFileWritten: interactive + ? undefined + : (relPath) => consola.info(` ${pc.green("+")} ${relPath}`), + }); + } catch (error) { + // `error()` on the spinner reports the failure with the right + // glyph; the outer dispatch handles the actual exit code. + spin?.error("Scaffold failed"); + throw error; + } + + spin?.stop(`Scaffolded ${result.written.length} files`); + if (!interactive) { + consola.success(`Scaffolded ${result.written.length} files in ${targetDir}`); + } + + printNextSteps(targetDir, inputs, interactive); +} + +// ────────────────────────────────────────────────────────────────────────── +// Per-field resolvers. Each consults the flag first; falls through to a +// clack prompt in interactive mode; falls through to `undefined` (→ the +// template emits a TODO) in non-interactive mode. +// ────────────────────────────────────────────────────────────────────────── + +/** + * The publisher resolution result. We always write a DID to the manifest + * (the runtime compares DIDs), but if the user typed a handle (or had + * one from their active session) we carry it through so the rendered + * manifest can emit a `// ` comment next to the pinned DID. + */ +interface PublisherResult { + did: string; + handle: string | undefined; +} + +/** + * Resolve the publisher to write into the manifest. Precedence: + * + * 1. `--publisher` flag (handle or DID; resolved to DID if a handle). + * 2. In `--yes` / non-TTY mode: the active session's handle/DID. + * 3. In interactive mode: a prompt pre-filled with the active session's + * handle (if logged in). + * 4. Otherwise: undefined → manifest gets a TODO placeholder. + * + * For user-typed handles, we eagerly resolve to a DID. The runtime only + * cares about the DID; writing it now means the post-publish write-back + * isn't needed for handle→DID conversion later. + */ +async function resolvePublisher( + args: InitArgs, + interactive: boolean, + session: SessionInfo | undefined, +): Promise { + const flag = nonEmpty(args.publisher); + if (flag !== undefined) { + return await resolvePublisherInput(flag, "--publisher"); + } + + // --yes / non-TTY with an active session: silently fill from session. + // The user can override by passing --publisher; we only reach here + // when they didn't. + if (!interactive) { + if (session) return { did: session.did, handle: session.handle ?? undefined }; + return undefined; + } + + const placeholder = session?.handle ?? "example.com"; + const defaultValue = session?.handle ?? undefined; + + const answer = await clack.text({ + message: session + ? "Atproto publisher (press enter to use your logged-in handle, or type a handle / DID)" + : "Atproto publisher (handle or DID, leave blank to fill in later)", + placeholder, + ...(defaultValue !== undefined && { defaultValue }), + validate: (raw) => { + // clack 1.x types `raw` as `string | undefined` because the + // user can submit without typing anything. Treat that as + // "blank, fine — user wants to fill it in later". + const v = (raw ?? "").trim(); + if (v.length === 0) return undefined; + if (isDid(v) || isHandle(v)) return undefined; + return 'Must be a handle (e.g. "example.com") or DID (e.g. "did:plc:...").'; + }, + }); + assertNotCancelled(answer); + const value = typeof answer === "string" ? answer.trim() : ""; + if (value.length === 0) return undefined; + return await resolvePublisherInput(value, "publisher"); +} + +/** + * Turn a raw publisher input (handle or DID) into a `PublisherResult`. + * DIDs pass through verbatim with no handle. Handles round-trip through + * the atproto resolver to produce a DID; the original handle is carried + * for the manifest comment. + * + * `sourceLabel` is used in error messages to disambiguate "the + * --publisher flag" from "the prompt". + */ +async function resolvePublisherInput(input: string, sourceLabel: string): Promise { + if (isDid(input)) { + return { did: input, handle: undefined }; + } + if (!isHandle(input)) { + throw new InputError( + `${sourceLabel} "${input}" is not a valid atproto handle or DID. Expected a handle (e.g. "example.com") or DID (e.g. "did:plc:abc...").`, + ); + } + try { + const did = await resolveHandleToDid(input); + return { did, handle: input }; + } catch (error) { + if (error instanceof PublisherCheckError) { + throw new InputError(error.message); + } + throw error; + } +} + +async function resolveLicense( + args: InitArgs, + interactive: boolean, + env: EnvironmentDefaults, +): Promise { + const flag = nonEmpty(args.license); + if (flag !== undefined) return flag; + // --yes / non-TTY: take whatever the environment told us, fall + // through to undefined (template defaults to "MIT"). + if (!interactive) return env.license; + const defaultValue = env.license ?? "MIT"; + const answer = await clack.text({ + message: "License (SPDX expression)", + defaultValue, + placeholder: defaultValue, + }); + assertNotCancelled(answer); + const value = typeof answer === "string" ? answer.trim() : ""; + return value.length === 0 ? undefined : value; +} + +async function resolveAuthor(args: InitArgs, interactive: boolean, env: EnvironmentDefaults) { + const flagName = nonEmpty(args["author-name"]); + const flagUrl = nonEmpty(args["author-url"]); + const flagEmail = nonEmpty(args["author-email"]); + + if (flagName !== undefined || flagUrl !== undefined || flagEmail !== undefined) { + // Any author flag set → assemble what we have. Missing sub-fields + // stay undefined; the template only emits the ones that are set. + // Fall back to environment values for the unset sub-fields so + // the user gets a complete author block when their git config + // has the info. + return { + name: flagName ?? env.authorName ?? "TODO: replace with your name", + ...((flagUrl ?? undefined) !== undefined && { url: flagUrl! }), + ...((flagEmail ?? env.authorEmail) !== undefined && { + email: flagEmail ?? env.authorEmail!, + }), + }; + } + + // --yes / non-TTY: use environment defaults only. If git config has + // both name and email, scaffolding picks them up silently. + if (!interactive) { + if (env.authorName === undefined && env.authorEmail === undefined) { + return undefined; + } + return { + name: env.authorName ?? "TODO: replace with your name", + ...(env.authorEmail !== undefined && { email: env.authorEmail }), + }; + } + + const nameAns = await clack.text({ + message: env.authorName + ? "Author name (press enter to use your git config)" + : "Author name (leave blank to fill in later)", + ...(env.authorName !== undefined && { defaultValue: env.authorName }), + placeholder: env.authorName ?? "Jane Doe", + }); + assertNotCancelled(nameAns); + const name = stringOrEmpty(nameAns); + if (name.length === 0) return undefined; + + const urlAns = await clack.text({ + message: "Author URL (optional)", + }); + assertNotCancelled(urlAns); + const url = stringOrEmpty(urlAns); + + const emailAns = await clack.text({ + message: env.authorEmail + ? "Author email (press enter to use your git config)" + : "Author email (optional)", + ...(env.authorEmail !== undefined && { defaultValue: env.authorEmail }), + placeholder: env.authorEmail ?? "jane@example.com", + }); + assertNotCancelled(emailAns); + const email = stringOrEmpty(emailAns); + + return { + name, + ...(url.length > 0 && { url }), + ...(email.length > 0 && { email }), + }; +} + +async function resolveDescription( + args: InitArgs, + interactive: boolean, + env: EnvironmentDefaults, +): Promise { + const flag = nonEmpty(args.description); + if (flag !== undefined) return flag; + if (!interactive) return env.description; + const answer = await clack.text({ + message: env.description + ? "Short description (press enter to use package.json#description)" + : "Short description (optional)", + ...(env.description !== undefined && { defaultValue: env.description }), + placeholder: env.description ?? "What does the plugin do?", + }); + assertNotCancelled(answer); + const value = stringOrEmpty(answer); + return value.length === 0 ? undefined : value; +} + +async function resolveRepo( + args: InitArgs, + interactive: boolean, + env: EnvironmentDefaults, +): Promise { + const flag = nonEmpty(args.repo); + if (flag !== undefined) return flag; + if (!interactive) return env.repo; + const answer = await clack.text({ + message: env.repo + ? "Source repository URL (press enter to use the detected origin)" + : "Source repository URL (optional)", + ...(env.repo !== undefined && { defaultValue: env.repo }), + placeholder: env.repo ?? "https://github.com/...", + validate: (raw) => { + const v = (raw ?? "").trim(); + if (v.length === 0) return undefined; + if (!v.startsWith("https://")) return "Must start with https://"; + return undefined; + }, + }); + assertNotCancelled(answer); + const value = stringOrEmpty(answer); + return value.length === 0 ? undefined : value; +} + +async function resolveSecurity(args: InitArgs, interactive: boolean) { + const flagEmail = nonEmpty(args["security-email"]); + const flagUrl = nonEmpty(args["security-url"]); + + if (flagEmail !== undefined || flagUrl !== undefined) { + return { + ...(flagEmail !== undefined && { email: flagEmail }), + ...(flagUrl !== undefined && { url: flagUrl }), + }; + } + if (!interactive) return undefined; + + const emailAns = await clack.text({ + message: "Security contact email (leave blank to provide a URL or fill in later)", + }); + assertNotCancelled(emailAns); + const email = stringOrEmpty(emailAns); + if (email.length > 0) return { email }; + + const urlAns = await clack.text({ + message: "Security contact URL (leave blank to fill in later)", + }); + assertNotCancelled(urlAns); + const url = stringOrEmpty(urlAns); + if (url.length === 0) return undefined; + return { url }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Session pre-fill +// ────────────────────────────────────────────────────────────────────────── + +/** + * The slice of the active session init cares about. Pulled out so the + * session-loading helper can return a plain shape without dragging the + * full StoredSession type through the rest of the command. + */ +interface SessionInfo { + did: string; + handle: string | null; +} + +/** + * Choose where to run environment probes against: + * + * - target dir if it exists (already a git repo with a package.json, + * scaffolding into it), + * - cwd otherwise (init creating a new sibling dir). + * + * Picking the target lets us read package.json#description / #license + * for the "scaffold into existing repo" case; falling back to cwd + * still gets us git user.name/user.email which live in the global + * config and don't depend on which dir we run from. + */ +async function pickProbeDir(targetDir: string): Promise { + const { stat } = await import("node:fs/promises"); + try { + const info = await stat(targetDir); + if (info.isDirectory()) return targetDir; + } catch { + // Target dir doesn't exist yet — that's the common case for + // `init my-plugin`. Fall through to cwd. + } + return process.cwd(); +} + +/** + * Load the active publisher session from the on-disk credentials store. + * Returns `undefined` on every failure path — the credentials file + * doesn't exist (fresh checkout), is corrupted, contains no current + * session, etc. init is reachable in all these states; we never want + * scaffolding to be blocked by a session lookup. + */ +async function loadCurrentSessionSilently(): Promise { + try { + const credentials = new FileCredentialStore(); + const current = await credentials.current(); + if (!current) return undefined; + return { did: current.did, handle: current.handle }; + } catch { + return undefined; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +/** + * Resolve `slug` and `targetDir` from the positional `name` + `--dir` + * combo. In all modes: + * + * - `init my-plugin` → slug="my-plugin", dir="./my-plugin" + * - `init my-plugin --dir foo` → slug="my-plugin", dir="./foo" + * - `init --dir foo` → slug=basename(foo), dir="./foo" + * - `init` → slug=basename(cwd), dir=cwd + */ +function resolveSlugAndDir(args: InitArgs): { slug: string; targetDir: string } { + const name = nonEmpty(args.name); + const dirArg = nonEmpty(args.dir); + if (name !== undefined) { + const slug = name; + const targetDir = dirArg !== undefined ? resolve(dirArg) : resolve(`./${slug}`); + return { slug, targetDir }; + } + const targetDir = dirArg !== undefined ? resolve(dirArg) : resolve("."); + const slug = basename(targetDir); + return { slug, targetDir }; +} + +function printNextSteps(targetDir: string, inputs: ScaffoldInputs, interactive: boolean): void { + const todos: string[] = []; + if (inputs.publisher === undefined) todos.push("publisher"); + if (inputs.author === undefined) todos.push("author"); + if (inputs.security === undefined) todos.push("security"); + + if (interactive) { + const lines: string[] = []; + if (todos.length > 0) { + lines.push( + `${pc.yellow("⚠")} Fill in the TODO placeholders in emdash-plugin.jsonc (${todos.join(", ")}) before bundling.`, + ); + } + lines.push(`1. ${pc.cyan(`cd ${targetDir}`)}`); + lines.push(`2. ${pc.cyan("pnpm install")}`); + lines.push(`3. ${pc.cyan("pnpm test")} confirm the scaffold passes its own test`); + lines.push(`4. Edit src/plugin.ts to add routes and hooks.`); + lines.push(`5. ${pc.cyan("emdash-registry bundle")} when ready to publish`); + clack.note(lines.join("\n"), "Next steps"); + clack.outro(`Plugin ready at ${pc.bold(targetDir)}`); + return; + } + + consola.info(""); + consola.info("Next steps:"); + if (todos.length > 0) { + consola.info( + ` ${pc.yellow("!")} Fill in the TODO placeholders in ${pc.dim(`${targetDir}/emdash-plugin.jsonc`)} (${todos.join(", ")}) before bundling.`, + ); + } + consola.info(` 1. ${pc.cyan(`cd ${targetDir}`)}`); + consola.info(` 2. ${pc.cyan("pnpm install")}`); + consola.info(` 3. ${pc.cyan("pnpm test")} # confirm the scaffold passes its own test`); + consola.info(` 4. Edit ${pc.dim("src/plugin.ts")} to add routes and hooks.`); + consola.info(` 5. ${pc.cyan("emdash-registry bundle")} # when ready to publish`); +} + +/** + * clack prompts return either the answer value or `Symbol.for("clack:cancel")` + * when the user hits Ctrl+C / ESC. We turn that into a clean cancel-and- + * exit rather than letting it propagate as an unrelated runtime error. + */ +function assertNotCancelled(value: unknown): void { + if (clack.isCancel(value)) { + clack.cancel("Cancelled."); + process.exit(0); + } +} + +/** + * Normalise clack's prompt return value to a trimmed string. `text()` + * returns `string | symbol`; the symbol case is handled separately by + * `assertNotCancelled`, so by the time this runs the value is either a + * string or something we treat as empty. + */ +function stringOrEmpty(value: unknown): string { + if (typeof value !== "string") return ""; + return value.trim(); +} + +/** + * Trim+empty-string treats `--flag=`, `--flag ""`, and an unprovided + * flag identically. citty leaves explicit empty strings as `""`; we + * normalise to `undefined` so downstream branching is uniform. + */ +function nonEmpty(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +/** + * Thrown for CLI-input validation failures (invalid slug, malformed + * publisher). Distinct from `InitError` (filesystem / conflict + * failures) so the outer dispatch can produce a different exit class + * if we ever add more granular codes. + */ +class InputError extends Error { + override readonly name = "InputError"; +} diff --git a/packages/registry-cli/src/index.ts b/packages/registry-cli/src/index.ts index e3c62c3f1..252228062 100644 --- a/packages/registry-cli/src/index.ts +++ b/packages/registry-cli/src/index.ts @@ -11,6 +11,7 @@ * - switch — change the active publisher session * - search — free-text search the aggregator * - info — show details about a package + * - init — scaffold a new sandboxed plugin * - bundle — bundle a plugin source directory into a tarball * - publish — publish a release that points at a hosted tarball * - validate — validate an emdash-plugin.jsonc manifest against the v1 schema @@ -23,6 +24,7 @@ import { defineCommand, runMain } from "citty"; import { bundleCommand } from "./bundle/command.js"; import { infoCommand } from "./commands/info.js"; +import { initCommand } from "./commands/init.js"; import { loginCommand } from "./commands/login.js"; import { logoutCommand } from "./commands/logout.js"; import { publishCommand } from "./commands/publish.js"; @@ -43,6 +45,7 @@ const main = defineCommand({ switch: switchCommand, search: searchCommand, info: infoCommand, + init: initCommand, bundle: bundleCommand, publish: publishCommand, validate: validateCommand, diff --git a/packages/registry-cli/src/init/environment.ts b/packages/registry-cli/src/init/environment.ts new file mode 100644 index 000000000..2acba5f9e --- /dev/null +++ b/packages/registry-cli/src/init/environment.ts @@ -0,0 +1,261 @@ +/** + * Environment-probe helpers for `emdash-registry init`. + * + * The goal: when the user runs init, pre-fill prompts with whatever the + * surrounding environment already knows. None of these probes are + * authoritative — they're just sensible defaults the user can override. + * + * Sources, in priority order per field: + * + * - git config (user.name, user.email): the canonical "who am I" on + * any developer machine. + * - `git remote get-url origin`: the most reliable source for the + * plugin's repo URL. + * - package.json#description / #license / #repository: catches the + * "scaffolding into an existing repo skeleton" case. + * + * Every probe swallows errors. The CLI uses these as soft defaults; a + * missing git binary, a non-git target dir, an unreadable package.json + * are all expected and silently fall through to "no default". + */ + +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Hard cap on the bytes we'll read from package.json. Pre-fills are a + * convenience; we don't want to OOM on a deranged 1GB package.json that + * happens to live in the target directory. + */ +const PACKAGE_JSON_MAX_BYTES = 64 * 1024; + +/** + * Timeout for the git child-process calls. Local git config / remote + * lookup should complete in milliseconds; anything that hangs is + * almost certainly a misconfigured remote or a network mount. We'd + * rather skip the pre-fill than make `init` feel slow. + */ +const GIT_TIMEOUT_MS = 2_000; + +/** + * Snapshot of pre-fill values discovered from the surrounding + * environment. Every field is optional — undefined means "nothing + * found", and the caller should fall back to its own default. + */ +export interface EnvironmentDefaults { + authorName: string | undefined; + authorEmail: string | undefined; + license: string | undefined; + description: string | undefined; + repo: string | undefined; +} + +/** + * Probe the environment for pre-fillable values. Inspects: + * + * 1. git config (global and per-dir) for user.name + user.email. + * 2. `git remote get-url origin` (with normalization) for the repo URL. + * 3. `/package.json` for description / license / repository. + * + * Errors in any one probe don't abort the others. The function returns + * whatever it could determine, with each missing field as undefined. + */ +export async function probeEnvironment(targetDir: string): Promise { + // Run independent probes in parallel — they don't depend on each + // other and each one is the slow path of a single fs/exec call. + const [authorName, authorEmail, repoFromGit, fromPackageJson] = await Promise.all([ + gitConfig("user.name", targetDir), + gitConfig("user.email", targetDir), + gitRemoteUrl(targetDir), + readPackageJson(targetDir), + ]); + + return { + authorName, + authorEmail, + // package.json#repository overrides the git remote only if it + // looks like a deliberate, complete URL (the git remote is the + // stronger signal of "where this code actually lives", but if a + // pre-existing package.json points elsewhere, respect it). + repo: fromPackageJson.repo ?? repoFromGit, + license: fromPackageJson.license, + description: fromPackageJson.description, + }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Git config +// ────────────────────────────────────────────────────────────────────────── + +/** + * Read a git config value, falling back from the target dir to the + * global config. Returns undefined if git isn't installed, the dir + * isn't a repo, or the key is unset. + * + * We use `git config --get` rather than `git config --show-origin` etc. + * because `--get` is the simplest "give me the effective value" form + * and it walks the repo→global→system fallback chain itself. + */ +async function gitConfig(key: string, cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["config", "--get", key], { + cwd, + timeout: GIT_TIMEOUT_MS, + // Limit output size to defend against a deliberately-bizarre + // git config value. 4 KiB is generous for "name" / "email". + maxBuffer: 4096, + }); + const value = stdout.trim(); + return value.length === 0 ? undefined : value; + } catch { + return undefined; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Git remote +// ────────────────────────────────────────────────────────────────────────── + +const GIT_SSH_RE = /^git@([^:]+):(.+?)(?:\.git)?$/; +const HTTPS_TRAILING_GIT_RE = /\.git$/; +const GIT_URL_PREFIX_RE = /^git\+/; + +/** + * Read `origin` remote URL and normalize to https. `git@github.com:foo/bar.git` + * becomes `https://github.com/foo/bar`. Returns undefined if there's no + * origin remote, no git, or the URL doesn't normalize to a recognisable + * shape. + */ +async function gitRemoteUrl(cwd: string): Promise { + let raw: string; + try { + const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { + cwd, + timeout: GIT_TIMEOUT_MS, + // 1 KiB is plenty for a URL; protects against weird remote + // names that include the entire output of a hostile hook. + maxBuffer: 1024, + }); + raw = stdout.trim(); + } catch { + return undefined; + } + if (raw.length === 0) return undefined; + return normalizeRepoUrl(raw); +} + +/** + * Normalize a git remote URL to the https form the manifest's `repo` + * field expects. Handles: + * + * git@github.com:foo/bar.git → https://github.com/foo/bar + * git@github.com:foo/bar → https://github.com/foo/bar + * https://github.com/foo/bar.git → https://github.com/foo/bar + * https://github.com/foo/bar → https://github.com/foo/bar + * ssh://git@... → undefined (not auto-rewritten; user can paste) + * + * Returns undefined for shapes we don't recognise; the manifest schema + * requires `https://...` and we'd rather omit the pre-fill than write + * a value the schema will reject. + */ +function normalizeRepoUrl(raw: string): string | undefined { + const sshMatch = GIT_SSH_RE.exec(raw); + if (sshMatch) { + const [, host, path] = sshMatch; + return `https://${host}/${path}`; + } + if (raw.startsWith("https://")) { + return raw.replace(HTTPS_TRAILING_GIT_RE, ""); + } + return undefined; +} + +// ────────────────────────────────────────────────────────────────────────── +// package.json +// ────────────────────────────────────────────────────────────────────────── + +interface PackageJsonDefaults { + license: string | undefined; + description: string | undefined; + repo: string | undefined; +} + +const EMPTY_PACKAGE_JSON: PackageJsonDefaults = { + license: undefined, + description: undefined, + repo: undefined, +}; + +/** + * Read `/package.json` and pull license, description, and + * a normalized repo URL out of it. Returns the empty defaults shape on + * any failure (missing file, parse error, oversized file). The + * scaffolder never trusts this output directly — values flow through + * `EnvironmentDefaults` which the caller may or may not use. + */ +async function readPackageJson(targetDir: string): Promise { + const path = join(targetDir, "package.json"); + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + return EMPTY_PACKAGE_JSON; + } + if (raw.length > PACKAGE_JSON_MAX_BYTES) { + return EMPTY_PACKAGE_JSON; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return EMPTY_PACKAGE_JSON; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return EMPTY_PACKAGE_JSON; + } + // JSON.parse returns `unknown`; the runtime check above narrows to + // "non-array object", and the cast just makes the property accesses + // below readable. The properties are still typed as `unknown` and + // each one gets its own runtime check. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above + const pkg = parsed as Record; + + const license = typeof pkg.license === "string" ? pkg.license.trim() : undefined; + const description = typeof pkg.description === "string" ? pkg.description.trim() : undefined; + const repo = repoFromPackageJsonRepository(pkg.repository); + + return { + license: license && license.length > 0 ? license : undefined, + description: description && description.length > 0 ? description : undefined, + repo, + }; +} + +/** + * Pull a repo URL out of package.json#repository. Handles both forms: + * + * "repository": "https://github.com/foo/bar" + * "repository": { "type": "git", "url": "git+https://github.com/foo/bar.git" } + * + * Normalizes through `normalizeRepoUrl`. Returns undefined if the + * value isn't a recognisable string or object-with-url. + */ +function repoFromPackageJsonRepository(value: unknown): string | undefined { + let raw: string | undefined; + if (typeof value === "string") { + raw = value; + } else if (value && typeof value === "object" && !Array.isArray(value)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- narrowed by runtime check above + const url = (value as Record).url; + if (typeof url === "string") raw = url; + } + if (raw === undefined || raw.length === 0) return undefined; + // npm accepts a leading `git+` on the URL (e.g. `git+https://...`). + // Strip it before normalisation so the https-check passes. + const stripped = raw.replace(GIT_URL_PREFIX_RE, ""); + return normalizeRepoUrl(stripped); +} diff --git a/packages/registry-cli/src/init/scaffold.ts b/packages/registry-cli/src/init/scaffold.ts new file mode 100644 index 000000000..058df5346 --- /dev/null +++ b/packages/registry-cli/src/init/scaffold.ts @@ -0,0 +1,156 @@ +/** + * Filesystem half of `emdash-registry init`. Takes the scaffold inputs + + * the target directory and writes the file tree. Pure templates live in + * `./templates.ts` so this module is just policy: which files exist, + * where they go, what happens when something's already there. + * + * Overwrite policy: refuses by default if any target file exists. Pass + * `--force` to allow overwriting (file-by-file, not directory-wide). + * This avoids the common "I ran init in the wrong dir and clobbered my + * package.json" surprise. + */ + +import { access, mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +import { + renderGitignore, + renderManifest, + renderPackageJson, + renderPluginEntry, + renderReadme, + renderTest, + renderTsconfig, + type ScaffoldInputs, +} from "./templates.js"; + +export type InitErrorCode = "TARGET_FILE_EXISTS" | "INVALID_SLUG" | "INVALID_PUBLISHER"; + +export class InitError extends Error { + override readonly name = "InitError"; + readonly code: InitErrorCode; + /** When set, the list of paths that already exist and would be overwritten. */ + readonly conflicts: string[]; + + constructor(code: InitErrorCode, message: string, conflicts: string[] = []) { + super(message); + this.code = code; + this.conflicts = conflicts; + } +} + +export interface ScaffoldOptions { + /** Absolute path to the target directory. Created if it doesn't exist. */ + targetDir: string; + /** Validated scaffold inputs. */ + inputs: ScaffoldInputs; + /** + * When true, overwrite existing files. When false, refuse with + * `TARGET_FILE_EXISTS` listing the conflicting paths. + */ + force: boolean; + /** Optional callback per file written, for CLI progress output. */ + onFileWritten?: (relativePath: string) => void; +} + +export interface ScaffoldResult { + /** Absolute paths of every file the scaffolder wrote. */ + written: string[]; +} + +/** + * The file tree the scaffolder produces. Order matters: parents must + * appear before children (the writer creates intermediate dirs from + * the file path, so order is informational rather than mandatory, but + * a consistent order keeps the per-file progress output predictable). + */ +const FILES = [ + "emdash-plugin.jsonc", + "package.json", + "tsconfig.json", + ".gitignore", + "README.md", + "src/plugin.ts", + "tests/plugin.test.ts", +] as const; + +type ScaffoldFile = (typeof FILES)[number]; + +/** + * Scaffold a plugin into `targetDir`. The target dir is created if it + * doesn't exist; missing intermediate directories under it are created + * per-file as needed. + * + * If any target file already exists and `force` is false, the function + * throws BEFORE writing anything. Partial writes don't happen — either + * every file gets written or none do. + */ +export async function scaffold(options: ScaffoldOptions): Promise { + const { targetDir, inputs, force, onFileWritten } = options; + const absDir = resolve(targetDir); + + // Pre-flight: check for conflicts. We do this before any write so a + // partial scaffold can't leave the target dir in a half-broken state. + if (!force) { + const conflicts: string[] = []; + for (const file of FILES) { + const absPath = join(absDir, file); + if (await exists(absPath)) { + conflicts.push(file); + } + } + if (conflicts.length > 0) { + throw new InitError( + "TARGET_FILE_EXISTS", + `Cannot scaffold into ${absDir}: the following files already exist. Pass --force to overwrite them.\n ${conflicts.join("\n ")}`, + conflicts, + ); + } + } + + await mkdir(absDir, { recursive: true }); + + const written: string[] = []; + for (const file of FILES) { + const absPath = join(absDir, file); + await mkdir(dirname(absPath), { recursive: true }); + await writeFile(absPath, renderFile(file, inputs), "utf8"); + written.push(absPath); + onFileWritten?.(file); + } + + return { written }; +} + +/** + * Dispatch each scaffold-file path to its renderer. Centralised here so + * adding a new file (icon, screenshot stub, docs page) is one place to + * update — append to FILES, add a case. + */ +function renderFile(file: ScaffoldFile, inputs: ScaffoldInputs): string { + switch (file) { + case "emdash-plugin.jsonc": + return renderManifest(inputs); + case "package.json": + return renderPackageJson(inputs); + case "tsconfig.json": + return renderTsconfig(); + case ".gitignore": + return renderGitignore(); + case "README.md": + return renderReadme(inputs); + case "src/plugin.ts": + return renderPluginEntry(); + case "tests/plugin.test.ts": + return renderTest(); + } +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} diff --git a/packages/registry-cli/src/init/templates.ts b/packages/registry-cli/src/init/templates.ts new file mode 100644 index 000000000..064646ee0 --- /dev/null +++ b/packages/registry-cli/src/init/templates.ts @@ -0,0 +1,354 @@ +/** + * Pure file-content producers for `emdash-registry init`. + * + * No filesystem access here — each function takes the inputs and returns + * the bytes that should land at the target path. Keeping these as pure + * functions makes the scaffolder testable without touching disk and + * keeps every template inspectable in one place. + * + * The shape produced is the three-file authoring contract: + * + * emdash-plugin.jsonc — identity + trust contract + profile + * src/plugin.ts — definePlugin({ routes, hooks }) + * package.json — private, type:module, devDeps only + * tsconfig.json — strict, standalone + * .gitignore + * README.md + * tests/plugin.test.ts + * + * No `src/index.ts`, no `dist/`, no tsdown. Source is the artefact; + * `emdash-registry bundle` transpiles at publish time. + */ + +import type { ManifestAuthor, ManifestSecurityContact } from "../manifest/schema.js"; + +/** + * Inputs to the scaffolder. + * + * Every field except `slug` is optional. Missing fields produce a + * placeholder in the generated manifest — either a `TODO:` line + * comment marking a value the author must fill in before the plugin + * works, or an outright omission of an optional field that the + * schema doesn't require. + * + * The contract: a scaffold produced from `{ slug }` alone is a valid + * starting point that the author can `cd` into, fix the TODOs in, + * and ship. There are no "init failed because you didn't pass enough + * flags" surprises. + */ +export interface ScaffoldInputs { + /** Plugin slug. Used as the directory name and the `slug` field. */ + slug: string; + /** + * Pre-filled publisher DID (resolved from a handle if the user + * typed one). When undefined, the manifest carries a TODO comment + * and an empty string; the author must set this before the plugin + * will load. + * + * The runtime only ever compares DIDs, so we write a DID — even if + * the user typed a handle. The handle, when known, is emitted as a + * `// ` line comment next to the pinned DID via + * `publisherHandle` below. + */ + publisher: string | undefined; + /** + * Optional handle that resolved to the `publisher` DID. Rendered as + * a `// ` line comment next to the pinned DID so a `git + * diff` reviewer sees a human-readable name for the publisher. The + * CLI ignores the comment on subsequent reads — only the DID is + * authoritative. + */ + publisherHandle: string | undefined; + /** SPDX license expression. Defaults to "MIT" when undefined. */ + license: string | undefined; + /** + * Author block. When undefined, the manifest carries a TODO + * comment and a placeholder name; author.url and author.email + * are omitted from the output entirely (the schema makes them + * optional). + */ + author: ManifestAuthor | undefined; + /** + * Security contact. When undefined, the manifest carries a TODO + * comment and a placeholder email; the author replaces it with + * a real contact before publishing. + */ + security: ManifestSecurityContact | undefined; + /** Optional short description. Omitted from the manifest when undefined. */ + description: string | undefined; + /** Optional repo URL. Omitted from the manifest when undefined. */ + repo: string | undefined; +} + +/** + * `emdash-plugin.jsonc` — the manifest. Includes a `$schema` pointer + * for editor completion. JSONC: tab-indented, no trailing comma on the + * final field. Fields omitted when the input is empty so the generated + * file is the smallest valid manifest, not a sea of `""`. + */ +export function renderManifest(input: ScaffoldInputs): string { + const lines: string[] = []; + lines.push("{"); + lines.push( + '\t"$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json",', + ); + lines.push(""); + lines.push(`\t"slug": ${jsonString(input.slug)},`); + lines.push('\t"version": "0.1.0",'); + + if (!input.publisher) { + lines.push( + '\t// TODO: set your atproto handle (e.g. "example.com") or DID before running `emdash-registry bundle` or any local-dev integration. The plugin cannot load without it.', + ); + lines.push('\t"publisher": "",'); + } else { + // When we know the handle that resolved to this DID, append it + // as a line comment for `git diff` readability. The handle is + // purely informational — the CLI never reads it back. + const trailer = input.publisherHandle ? ` // ${input.publisherHandle}` : ""; + lines.push(`\t"publisher": ${jsonString(input.publisher)},${trailer}`); + } + + lines.push(""); + lines.push(`\t"license": ${jsonString(input.license ?? "MIT")},`); + + if (input.author) { + lines.push(`\t"author": ${renderAuthor(input.author)},`); + } else { + lines.push( + "\t// TODO: replace the placeholder with your real name and (optionally) url/email before publishing.", + ); + lines.push( + `\t"author": { "name": ${jsonString(`TODO: replace with your name (${input.slug} author)`)} },`, + ); + } + + if (input.security) { + lines.push(`\t"security": ${renderSecurityContact(input.security)},`); + } else { + lines.push( + "\t// TODO: replace the placeholder with a real security contact email or url before publishing. The lexicon mandates at least one.", + ); + lines.push('\t"security": { "email": "TODO@example.com" },'); + } + + if (input.description) { + lines.push(`\t"description": ${jsonString(input.description)},`); + } + if (input.repo) { + lines.push(`\t"repo": ${jsonString(input.repo)},`); + } + + lines.push(""); + lines.push("\t// Trust contract — what runtime APIs the plugin asks for."); + lines.push("\t// Empty arrays mean no extra privileges beyond logging,"); + lines.push("\t// KV, and route/hook registration. Changing these between"); + lines.push("\t// releases requires a version bump because installed"); + lines.push("\t// users have consented to the old contract."); + lines.push('\t"capabilities": [],'); + lines.push('\t"allowedHosts": [],'); + lines.push('\t"storage": {}'); + lines.push("}"); + lines.push(""); + return lines.join("\n"); +} + +/** + * Render a single author object as a JSONC inline value. Always + * single-line so the generated manifest stays compact. + */ +function renderAuthor(author: ManifestAuthor): string { + const parts: string[] = [`"name": ${jsonString(author.name)}`]; + if (author.url) parts.push(`"url": ${jsonString(author.url)}`); + if (author.email) parts.push(`"email": ${jsonString(author.email)}`); + return `{ ${parts.join(", ")} }`; +} + +/** + * Render a single security contact as a JSONC inline value. + */ +function renderSecurityContact(contact: ManifestSecurityContact): string { + const parts: string[] = []; + if (contact.email) parts.push(`"email": ${jsonString(contact.email)}`); + if (contact.url) parts.push(`"url": ${jsonString(contact.url)}`); + return `{ ${parts.join(", ")} }`; +} + +/** + * `src/plugin.ts` — runtime code. One route, no hooks. Demonstrates the + * three primitives a sandboxed plugin author needs: definePlugin (the + * types helper), the PluginContext type, and a return value (the runtime + * routes it as JSON). + */ +export function renderPluginEntry(): string { + return `import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +/** + * Sandboxed plugin entry. \`definePlugin\` is a types helper — it + * passes the definition through verbatim and gives the TS compiler + * the right inference for hook and route handler signatures. + */ +export default definePlugin({ +\troutes: { +\t\thello: { +\t\t\thandler: async (_routeCtx, ctx: PluginContext) => { +\t\t\t\tctx.log.info("hello route called", { pluginId: ctx.plugin.id }); +\t\t\t\treturn { greeting: "hello", pluginId: ctx.plugin.id }; +\t\t\t}, +\t\t}, +\t}, +}); +`; +} + +/** + * `package.json` — toolchain only. Private (won't accidentally get + * published to npm), type:module (Node ESM), no main / exports / files / + * build scripts. The package exists for vitest + tsc to find their feet, + * not for distribution. + */ +export function renderPackageJson(input: ScaffoldInputs): string { + const pkg = { + name: input.slug, + version: "0.1.0", + private: true, + type: "module", + scripts: { + typecheck: "tsc --noEmit", + test: "vitest run", + }, + devDependencies: { + emdash: "^6.0.0", + typescript: "^5.9.0", + vitest: "^4.1.0", + }, + }; + return `${JSON.stringify(pkg, null, "\t")}\n`; +} + +/** + * `tsconfig.json` — strict, ES2022, bundler resolution. Mirrors the + * `node22 + bundler` style the rest of the EmDash workspace uses, but + * doesn't extend anything from the workspace so the scaffold is + * self-contained. + */ +export function renderTsconfig(): string { + const config = { + compilerOptions: { + target: "ES2022", + module: "preserve", + moduleResolution: "bundler", + strict: true, + esModuleInterop: true, + verbatimModuleSyntax: true, + skipLibCheck: true, + types: [], + }, + include: ["src/**/*", "tests/**/*"], + exclude: ["node_modules"], + }; + return `${JSON.stringify(config, null, "\t")}\n`; +} + +/** + * `.gitignore` — node_modules only. No `dist/` because there is no + * `dist/`. + */ +export function renderGitignore(): string { + return "node_modules/\n"; +} + +/** + * `README.md` — three sections: develop, publish, version-bump rules. + * Nothing else. The author can extend; the scaffold doesn't pre-write + * marketing copy. + */ +export function renderReadme(input: ScaffoldInputs): string { + const title = input.slug; + return `# ${title} + +A sandboxed plugin for [EmDash CMS](https://emdashcms.com). + +## Develop + +\`\`\`sh +pnpm install +pnpm typecheck +pnpm test +\`\`\` + +To test against a running EmDash site, link this plugin into the site's +workspace and reference it from the integration's plugin loader. (Local- +dev wiring details land alongside the \`localPlugin\` helper from +\`@emdash-cms/registry-cli/dev\`.) + +## Publish + +\`\`\`sh +emdash-registry login # if you're not already logged in +emdash-registry bundle # produces dist/${title}-.tar.gz +# upload that tarball to a public URL, then: +emdash-registry publish --url https://your-host/... +\`\`\` + +## Version bumps + +Bump \`version\` in \`emdash-plugin.jsonc\` (and \`package.json\`, for +tooling) when you ship a release. **Bump major** for breaking changes, +**bump minor** for new routes or hooks, **bump patch** for fixes. + +You MUST bump version whenever you change \`capabilities\`, \`allowedHosts\`, +or \`storage\` in the manifest. Installed users have consented to the +old trust contract; a change without a version bump would let new +behaviour slip past consent. +`; +} + +/** + * `tests/plugin.test.ts` — one passing test that exercises the + * hello route. Uses a minimal stubbed PluginContext rather than + * pulling in the runtime: the test asserts the handler returns the + * expected shape, not that the runtime wires it up correctly. + */ +export function renderTest(): string { + return `import { describe, expect, it } from "vitest"; + +import plugin from "../src/plugin.js"; + +describe("hello route", () => { +\tit("returns a greeting", async () => { +\t\tconst handler = plugin.routes?.hello; +\t\tif (!handler || typeof handler !== "object" || !("handler" in handler)) { +\t\t\tthrow new Error("hello route handler not found"); +\t\t} +\t\tconst result = await handler.handler({} as never, makeTestContext()); +\t\texpect(result).toEqual({ greeting: "hello", pluginId: "test-plugin" }); +\t}); +}); + +function makeTestContext() { +\t// Minimal stub PluginContext: the hello route only reads +\t// \`ctx.log.info\` and \`ctx.plugin.id\`. Real PluginContext has many +\t// more methods; add them as your plugin grows. +\treturn { +\t\tplugin: { id: "test-plugin", version: "0.1.0" }, +\t\tlog: { +\t\t\tinfo: () => {}, +\t\t\twarn: () => {}, +\t\t\terror: () => {}, +\t\t\tdebug: () => {}, +\t\t}, +\t} as unknown as import("emdash").PluginContext; +} +`; +} + +/** + * JSON-stringify a string with double quotes and proper escaping. + * Trivially `JSON.stringify` does the job, but wrapping it gives us + * a single place to switch quote styles or escape behaviour later. + */ +function jsonString(value: string): string { + return JSON.stringify(value); +} diff --git a/packages/registry-cli/src/manifest/publisher.ts b/packages/registry-cli/src/manifest/publisher.ts index 06de6b181..b799cb44d 100644 --- a/packages/registry-cli/src/manifest/publisher.ts +++ b/packages/registry-cli/src/manifest/publisher.ts @@ -113,8 +113,12 @@ export async function checkPublisher(input: { * Resolve an atproto handle to a DID via the same actor-resolver the * OAuth flow uses (DoH + .well-known). Surfaces resolution failures * with a clear hint pointing the user at the DID-pin escape hatch. + * + * Exported so the `init` command can resolve a handle the user typed + * (or pulled from their active session) before writing it to the + * manifest — same primitive, same failure mode, same error code. */ -async function resolveHandleToDid(handle: Handle): Promise { +export async function resolveHandleToDid(handle: Handle): Promise { const resolver = createActorResolver(); try { const resolved = await resolver.resolve(handle); diff --git a/packages/registry-cli/tests/init-environment.test.ts b/packages/registry-cli/tests/init-environment.test.ts new file mode 100644 index 000000000..253cedbee --- /dev/null +++ b/packages/registry-cli/tests/init-environment.test.ts @@ -0,0 +1,157 @@ +/** + * Coverage for the init scaffolder's environment probe. + * + * The probe reads three external sources: git config, git remote, and + * package.json. Tests focus on the parts we control directly — repo + * URL normalisation and package.json field extraction. The git-config + * and git-remote subprocess calls are tested indirectly via `init` + * integration tests; mocking child_process gets brittle fast and the + * subprocess wrapper is a thin layer that doesn't have much to fail. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { probeEnvironment } from "../src/init/environment.js"; + +describe("probeEnvironment — package.json extraction", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "emdash-env-test-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns all-undefined for a directory with no package.json", async () => { + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + // Note: authorName / authorEmail may be set from the user's + // global git config; we don't assert on them here. The package + // extraction is the focus. + }); + + it("reads license, description, and repository from package.json", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + license: "Apache-2.0", + description: "A test plugin", + repository: "https://github.com/example/test", + }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBe("Apache-2.0"); + expect(env.description).toBe("A test plugin"); + expect(env.repo).toBe("https://github.com/example/test"); + }); + + it("normalizes git@github.com SSH URLs to https", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + repository: { type: "git", url: "git+ssh://git@github.com:example/test.git" }, + }), + "utf8", + ); + // SSH URL with the `ssh://` scheme prefix isn't the shape our + // normaliser handles — the user-facing common case is + // `git@host:path`, not `ssh://git@host/path`. The probe + // returns undefined; the prompt's fallback chain handles it. + const env = await probeEnvironment(dir); + expect(env.repo).toBeUndefined(); + }); + + it("strips the .git suffix and the git+ prefix from package.json#repository.url", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + repository: { type: "git", url: "git+https://github.com/example/test.git" }, + }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.repo).toBe("https://github.com/example/test"); + }); + + it("treats a malformed package.json as missing", async () => { + await writeFile(join(dir, "package.json"), "{ not valid json", "utf8"); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + expect(env.repo).toBeUndefined(); + }); + + it("treats a non-object package.json as missing", async () => { + // Valid JSON but not an object — pathological but possible. + await writeFile(join(dir, "package.json"), "[]", "utf8"); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + }); + + it("ignores oversized package.json (defence against weird files)", async () => { + // Build a 100 KiB file. The cap is 64 KiB; the probe should + // skip rather than buffer the whole thing. + const fluff = " ".repeat(100 * 1024); + await writeFile( + join(dir, "package.json"), + `{ "license": "MIT", "_fluff": "${fluff}" }`, + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + }); + + it("treats empty-string license / description as undefined", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", license: "", description: " " }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBeUndefined(); + expect(env.description).toBeUndefined(); + }); + + it("trims whitespace from license / description values", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", license: " MIT ", description: " hello " }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.license).toBe("MIT"); + expect(env.description).toBe("hello"); + }); + + it("accepts repository as a bare string", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", repository: "https://github.com/example/test.git" }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.repo).toBe("https://github.com/example/test"); + }); + + it("returns undefined for unrecognised repository shapes", async () => { + await writeFile( + join(dir, "package.json"), + JSON.stringify({ name: "test", repository: { type: "git" /* no url */ } }), + "utf8", + ); + const env = await probeEnvironment(dir); + expect(env.repo).toBeUndefined(); + }); +}); diff --git a/packages/registry-cli/tests/init-scaffold.test.ts b/packages/registry-cli/tests/init-scaffold.test.ts new file mode 100644 index 000000000..2215d09cd --- /dev/null +++ b/packages/registry-cli/tests/init-scaffold.test.ts @@ -0,0 +1,170 @@ +/** + * End-to-end coverage for `scaffold()`: the filesystem half of init. + * + * Each test runs against a fresh tempdir so writes don't collide. The + * suite's job is to verify the file tree, the overwrite policy, and + * the "the scaffold round-trips through the loader" invariant — the + * scaffolder shouldn't produce a manifest that its own validator + * rejects (when the user has supplied all required fields). + */ + +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { InitError, scaffold } from "../src/init/scaffold.js"; +import type { ScaffoldInputs } from "../src/init/templates.js"; +import { loadManifest } from "../src/manifest/load.js"; + +const FULL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: "did:plc:abc123def456", + publisherHandle: "example.com", + license: "MIT", + author: { name: "Jane Doe" }, + security: { email: "security@example.com" }, + description: undefined, + repo: undefined, +}; + +const MINIMAL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: undefined, + publisherHandle: undefined, + license: undefined, + author: undefined, + security: undefined, + description: undefined, + repo: undefined, +}; + +describe("scaffold", () => { + let targetDir: string; + + beforeEach(async () => { + // Each test gets its own tempdir, then immediately removes the + // dir so the scaffolder is creating from scratch (matches what + // a real `init` invocation does into a brand-new directory). + const root = await mkdtemp(join(tmpdir(), "emdash-init-test-")); + targetDir = join(root, "plugin"); + }); + + afterEach(async () => { + // rm the parent root, not just targetDir, so we don't leak + // the mkdtemp parent behind. + const root = targetDir.replace(/\/plugin$/, ""); + await rm(root, { recursive: true, force: true }); + }); + + it("writes the expected file tree", async () => { + const result = await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); + expect(result.written).toHaveLength(7); + + // Spot-check the structure rather than pinning the array order. + const fileSet = new Set(result.written.map((p) => p.replace(`${targetDir}/`, ""))); + expect(fileSet.has("emdash-plugin.jsonc")).toBe(true); + expect(fileSet.has("package.json")).toBe(true); + expect(fileSet.has("tsconfig.json")).toBe(true); + expect(fileSet.has(".gitignore")).toBe(true); + expect(fileSet.has("README.md")).toBe(true); + expect(fileSet.has("src/plugin.ts")).toBe(true); + expect(fileSet.has("tests/plugin.test.ts")).toBe(true); + }); + + it("produces a manifest that the loader accepts", async () => { + await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); + const { manifest } = await loadManifest(targetDir); + expect(manifest.slug).toBe("gallery"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.publisher).toBe("did:plc:abc123def456"); + expect(manifest.license).toBe("MIT"); + }); + + it("produces a minimal manifest that round-trips through the loader (with empty publisher)", async () => { + // Minimal scaffold writes TODO placeholders. The loader's JSONC + // parse succeeds; the schema rejects on `publisher` being empty. + // We catch that explicitly so the user knows what to fix. + await scaffold({ targetDir, inputs: MINIMAL_INPUTS, force: false }); + await expect(loadManifest(targetDir)).rejects.toMatchObject({ + name: "ManifestError", + code: "MANIFEST_VALIDATION_ERROR", + }); + }); + + it("refuses to overwrite an existing file without --force", async () => { + // Pre-create one of the target files with different content. + const { mkdir } = await import("node:fs/promises"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "package.json"), "{}", "utf8"); + + await expect(scaffold({ targetDir, inputs: FULL_INPUTS, force: false })).rejects.toMatchObject({ + name: "InitError", + code: "TARGET_FILE_EXISTS", + conflicts: ["package.json"], + }); + + // The original file must be untouched. + const contents = await readFile(join(targetDir, "package.json"), "utf8"); + expect(contents).toBe("{}"); + }); + + it("does not partially write when a conflict is detected", async () => { + // Same setup as above. The conflict check runs BEFORE any + // write, so nothing else should appear in the target dir. + const { mkdir } = await import("node:fs/promises"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "package.json"), "{}", "utf8"); + + await expect(scaffold({ targetDir, inputs: FULL_INPUTS, force: false })).rejects.toThrow( + InitError, + ); + + const entries = await readdir(targetDir); + // Only the pre-existing file should be there. + expect(entries).toEqual(["package.json"]); + }); + + it("overwrites existing files when --force is set", async () => { + const { mkdir } = await import("node:fs/promises"); + await mkdir(targetDir, { recursive: true }); + await writeFile(join(targetDir, "package.json"), "{}", "utf8"); + + await scaffold({ targetDir, inputs: FULL_INPUTS, force: true }); + const contents = await readFile(join(targetDir, "package.json"), "utf8"); + // The scaffold wrote the real package.json over the stub. + const parsed = JSON.parse(contents) as { name: string }; + expect(parsed.name).toBe("gallery"); + }); + + it("creates intermediate directories (src/, tests/)", async () => { + await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); + const srcStat = await stat(join(targetDir, "src")); + const testsStat = await stat(join(targetDir, "tests")); + expect(srcStat.isDirectory()).toBe(true); + expect(testsStat.isDirectory()).toBe(true); + }); + + it("invokes onFileWritten once per file in scaffold order", async () => { + const calls: string[] = []; + await scaffold({ + targetDir, + inputs: FULL_INPUTS, + force: false, + onFileWritten: (rel) => calls.push(rel), + }); + // The seven files, in some deterministic order (see FILES in + // scaffold.ts). The exact order is part of the API surface + // for CLI progress output. + expect(calls).toEqual([ + "emdash-plugin.jsonc", + "package.json", + "tsconfig.json", + ".gitignore", + "README.md", + "src/plugin.ts", + "tests/plugin.test.ts", + ]); + }); +}); diff --git a/packages/registry-cli/tests/init-templates.test.ts b/packages/registry-cli/tests/init-templates.test.ts new file mode 100644 index 000000000..0bd30bb64 --- /dev/null +++ b/packages/registry-cli/tests/init-templates.test.ts @@ -0,0 +1,276 @@ +/** + * Coverage for the init scaffolder's pure template functions. + * + * Tests focus on the manifest renderer because that's the file users + * see first and the one whose shape has to satisfy the schema. The + * other templates (package.json, tsconfig, README) get smoke checks + * that they produce valid JSON / non-empty content; their exact + * wording is verified by the integration test in init-scaffold.test.ts. + */ + +import { describe, expect, it } from "vitest"; + +import { + renderGitignore, + renderManifest, + renderPackageJson, + renderPluginEntry, + renderReadme, + renderTest, + renderTsconfig, + type ScaffoldInputs, +} from "../src/init/templates.js"; +import { ManifestSchema } from "../src/manifest/schema.js"; + +const FULL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: "did:plc:abc123def456", + publisherHandle: "example.com", + license: "MIT", + author: { name: "Jane Doe", url: "https://example.com", email: "jane@example.com" }, + security: { email: "security@example.com" }, + description: "Image gallery plugin", + repo: "https://github.com/example/gallery", +}; + +const MINIMAL_INPUTS: ScaffoldInputs = { + slug: "gallery", + publisher: undefined, + publisherHandle: undefined, + license: undefined, + author: undefined, + security: undefined, + description: undefined, + repo: undefined, +}; + +describe("renderManifest (fully-populated)", () => { + it("produces a manifest that passes the schema", () => { + const source = renderManifest(FULL_INPUTS); + // JSONC parser strips comments and trailing commas before + // validation. We parse via the same loader path the CLI uses + // elsewhere, but for the test a quick `parse` from jsonc-parser + // is enough — we only need to confirm the rendered bytes + // validate. + const parsed = parseJsonc(source); + const result = ManifestSchema.safeParse(parsed); + expect(result.success).toBe(true); + }); + + it("renders identity, license, author, security, description, repo", () => { + const source = renderManifest(FULL_INPUTS); + expect(source).toContain('"slug": "gallery"'); + expect(source).toContain('"version": "0.1.0"'); + expect(source).toContain('"publisher": "did:plc:abc123def456"'); + expect(source).toContain('"license": "MIT"'); + expect(source).toContain('"name": "Jane Doe"'); + // author's url. The publisher comment also contains "example.com", + // so we anchor on the author block by looking for the + // url-key shape rather than the bare hostname. + expect(source).toContain('"url": "https://example.com"'); + expect(source).toContain('"email": "jane@example.com"'); + expect(source).toContain('"email": "security@example.com"'); + expect(source).toContain('"description": "Image gallery plugin"'); + expect(source).toContain('"repo": "https://github.com/example/gallery"'); + }); + + it("includes the handle as a line comment next to the pinned DID", () => { + const source = renderManifest(FULL_INPUTS); + const publisherLine = source.split("\n").find((l) => l.includes('"publisher"'))!; + expect(publisherLine).toBeDefined(); + expect(publisherLine).toContain("// example.com"); + }); + + it("omits the publisher comment when no handle is known (DID-only input)", () => { + const source = renderManifest({ ...FULL_INPUTS, publisherHandle: undefined }); + const publisherLine = source.split("\n").find((l) => l.includes('"publisher"'))!; + expect(publisherLine).toBeDefined(); + expect(publisherLine).not.toContain("//"); + }); + + it("includes the $schema reference for IDE completion", () => { + const source = renderManifest(FULL_INPUTS); + expect(source).toContain( + '"$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json"', + ); + }); + + it("emits empty default arrays for the trust contract", () => { + // init starts with no declared capabilities. The author opts in. + const source = renderManifest(FULL_INPUTS); + expect(source).toContain('"capabilities": []'); + expect(source).toContain('"allowedHosts": []'); + expect(source).toContain('"storage": {}'); + }); +}); + +describe("renderManifest (minimal — no flags, no prompts)", () => { + it("produces a manifest with TODO placeholders", () => { + const source = renderManifest(MINIMAL_INPUTS); + // Three TODOs: publisher, author, security. License has a + // default (MIT) so it never carries a TODO. + const todoLines = source.split("\n").filter((line) => line.includes("TODO")); + expect(todoLines.length).toBeGreaterThanOrEqual(3); + // At least one TODO mentions atproto (publisher), one mentions + // the author name, one mentions security. + expect(todoLines.some((l) => /atproto handle|DID/i.test(l))).toBe(true); + expect(todoLines.some((l) => /name|author/i.test(l))).toBe(true); + expect(todoLines.some((l) => /security/i.test(l))).toBe(true); + }); + + it("emits an empty publisher value the schema will reject", () => { + // The TODO is visible to the user; the empty string is what + // schema validation hits. This is intentional: the manifest is + // "valid JSONC, schema-invalid until publisher is filled in". + const source = renderManifest(MINIMAL_INPUTS); + expect(source).toContain('"publisher": ""'); + }); + + it("defaults license to MIT when unset", () => { + const source = renderManifest(MINIMAL_INPUTS); + expect(source).toContain('"license": "MIT"'); + }); + + it("renders to the smallest plausible manifest", () => { + // description and repo are truly-optional fields. They must + // not appear when unset (no empty-string keys lying around). + const source = renderManifest(MINIMAL_INPUTS); + expect(source).not.toMatch(/"description":/); + expect(source).not.toMatch(/"repo":/); + }); +}); + +describe("renderManifest (partial author/security)", () => { + it("emits author.url and author.email only when provided", () => { + const source = renderManifest({ + ...FULL_INPUTS, + author: { name: "Jane Doe" }, // no url, no email + }); + expect(source).toContain('"name": "Jane Doe"'); + expect(source).not.toContain('"url":'); + expect(source).not.toContain('"jane@example.com"'); + }); + + it("emits security.url when only the url is provided", () => { + const source = renderManifest({ + ...FULL_INPUTS, + security: { url: "https://example.com/security" }, + }); + expect(source).toContain('"url": "https://example.com/security"'); + }); +}); + +describe("renderPackageJson", () => { + it("uses the slug as the package name and marks it private", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.name).toBe("gallery"); + expect(parsed.private).toBe(true); + expect(parsed.type).toBe("module"); + }); + + it("ships typecheck + test scripts only — no build", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(Object.keys(parsed.scripts)).toEqual(["typecheck", "test"]); + }); + + it("doesn't ship main, exports, or files (no npm publishing path)", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.main).toBeUndefined(); + expect(parsed.exports).toBeUndefined(); + expect(parsed.files).toBeUndefined(); + }); +}); + +describe("renderTsconfig", () => { + it("produces a strict standalone tsconfig", () => { + const parsed = JSON.parse(renderTsconfig()); + expect(parsed.compilerOptions.strict).toBe(true); + // No outDir / declaration — source is the artefact, bundle + // transpiles at publish time. + expect(parsed.compilerOptions.outDir).toBeUndefined(); + expect(parsed.compilerOptions.declaration).toBeUndefined(); + }); + + it("includes both src and tests", () => { + const parsed = JSON.parse(renderTsconfig()); + expect(parsed.include).toContain("src/**/*"); + expect(parsed.include).toContain("tests/**/*"); + }); +}); + +describe("renderPluginEntry", () => { + it("imports definePlugin and PluginContext from emdash", () => { + const source = renderPluginEntry(); + expect(source).toContain('import { definePlugin } from "emdash"'); + expect(source).toContain('import type { PluginContext } from "emdash"'); + }); + + it("default-exports a definePlugin call with a hello route", () => { + const source = renderPluginEntry(); + expect(source).toContain("export default definePlugin"); + expect(source).toContain("hello:"); + expect(source).toContain("greeting:"); + }); +}); + +describe("renderTest", () => { + it("imports the plugin and exercises the hello route", () => { + const source = renderTest(); + expect(source).toContain('from "../src/plugin.js"'); + expect(source).toContain("hello"); + expect(source).toContain("expect(result)"); + }); +}); + +describe("renderGitignore", () => { + it("ignores node_modules", () => { + expect(renderGitignore()).toContain("node_modules"); + }); + + it("does not ignore dist — the scaffold has no dist", () => { + expect(renderGitignore()).not.toContain("dist"); + }); +}); + +describe("renderReadme", () => { + it("documents the publish path", () => { + const source = renderReadme(FULL_INPUTS); + expect(source).toContain("emdash-registry bundle"); + expect(source).toContain("emdash-registry publish"); + }); + + it("documents version-bump rules for the trust contract", () => { + const source = renderReadme(FULL_INPUTS); + expect(source).toContain("capabilities"); + expect(source).toContain("trust contract"); + }); + + it("uses the slug as the title", () => { + const source = renderReadme(FULL_INPUTS); + expect(source.split("\n")[0]).toBe("# gallery"); + }); +}); + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +/** + * Parse JSONC for testing the rendered manifest. We use the + * jsonc-parser dep directly here rather than going through the full + * loader because the loader requires a file path and we want to + * keep these tests in-memory. + */ +function parseJsonc(source: string): unknown { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { parse } = require("jsonc-parser") as typeof import("jsonc-parser"); + const errors: import("jsonc-parser").ParseError[] = []; + const value: unknown = parse(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }); + if (errors.length > 0) { + throw new Error(`JSONC parse errors: ${JSON.stringify(errors)}`); + } + return value; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304acc467..65bc69b7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1843,6 +1843,9 @@ importers: '@atcute/oauth-node-client': specifier: 'catalog:' version: 1.1.0 + '@clack/prompts': + specifier: ^1.4.0 + version: 1.4.0 '@emdash-cms/plugin-types': specifier: workspace:* version: link:../plugin-types @@ -2387,7 +2390,7 @@ packages: wrangler: ^4.61.1 '@astrojs/cloudflare@https://pkg.pr.new/@astrojs/cloudflare@94d342d': - resolution: {integrity: sha512-Bt+G512Dr1SqYdsza6HOLP2azfHg0m5UE0s6SBGX77g+ThFV95Nai5boyM8HO3jVpqwVPPh+5ycMptjrtzv7Yg==, tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} + resolution: {tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} version: 13.1.10 peerDependencies: astro: ^6.0.0 @@ -2489,7 +2492,7 @@ packages: engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} '@astrojs/telemetry@https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d': - resolution: {integrity: sha512-xfarx9l9HW3YpytsM2OpnD3aADtxueYWk6xg81PmVRLxfszskZzoaPVvZwfmqnpIxjBP1tOF1RLVaS10TwnNLQ==, tarball: https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d} + resolution: {tarball: https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d} version: 3.3.0 engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} @@ -2948,12 +2951,20 @@ packages: '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + '@clack/prompts@0.10.1': resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kumo@1.16.0': resolution: {integrity: sha512-uCrj7jGPvdXj8lrdQBfMGKzV3JTDi7hUBsLf4jpirD7QHvZMsGe6XuU+KKvQFqDTmj5ELXQVES4YVoducxZ7Tg==} hasBin: true @@ -6450,7 +6461,7 @@ packages: hasBin: true astro@https://pkg.pr.new/astro@94d342d: - resolution: {integrity: sha512-1XlhRGRCQP4L5KPZUgSRCKOD28aKiGYQ8TBAxBIJvFV/HUuct3eHvc7sY/krhhCAju81JMlvbWU+1XVzltgZTQ==, tarball: https://pkg.pr.new/astro@94d342d} + resolution: {tarball: https://pkg.pr.new/astro@94d342d} version: 6.1.7 engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -7179,9 +7190,18 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -11707,6 +11727,11 @@ snapshots: dependencies: sisteransi: 1.0.5 + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@clack/prompts@0.10.1': dependencies: '@clack/core': 0.4.2 @@ -11718,6 +11743,13 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@cloudflare/kumo@1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -16329,8 +16361,18 @@ snapshots: fast-redact@3.5.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fastq@1.17.1: dependencies: reusify: 1.0.4 From 44e8501a7fd943914fbf6d2276beaeb2177afcba Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 15 May 2026 06:56:28 +0100 Subject: [PATCH 04/16] feat(plugins): migrate in-tree sandboxed plugins to the new layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth phase of the redesign (#1028b). Moves the 5 in-tree sandboxed plugins to the manifest + src/plugin.ts shape so they become the canonical references a plugin author looks at. Each plugin's layout changes from: src/index.ts (descriptor factory, ~50 lines) src/sandbox-entry.ts (runtime code via definePlugin) package.json (main / exports / files / build scripts) to: emdash-plugin.jsonc (identity + trust contract + admin surface) src/plugin.ts (runtime code, unchanged) package.json (private, typecheck script only) Plugins migrated: - atproto - audit-log - marketplace-test - sandboxed-test - webhook-notifier Schema gains `admin` (pages + widgets) since four of the five plugins declare admin surface. Mirrors PluginAdminPage / PluginDashboardWidget in core. Atproto's plugin.test.ts rewritten to assert against the manifest instead of the deleted descriptor factory. KNOWN BREAKAGE: demos that import the old factories (`auditLogPlugin()`, `webhookNotifierPlugin()`) from astro.config.mjs are broken until the next commit ships `@emdash-cms/registry-cli/dev`'s `localPlugin(dir)` helper and updates the demos. All published plugins still work — the bundled manifest.json shape is unchanged. Only authoring changed. --- packages/plugins/atproto/emdash-plugin.jsonc | 26 +++ packages/plugins/atproto/package.json | 30 +-- packages/plugins/atproto/src/index.ts | 44 ----- .../src/{sandbox-entry.ts => plugin.ts} | 0 ...x-entry.test.ts => plugin-runtime.test.ts} | 6 +- packages/plugins/atproto/tests/plugin.test.ts | 95 ++++++---- .../plugins/audit-log/emdash-plugin.jsonc | 26 +++ packages/plugins/audit-log/package.json | 33 +--- packages/plugins/audit-log/src/index.ts | 55 ------ .../src/{sandbox-entry.ts => plugin.ts} | 0 .../marketplace-test/emdash-plugin.jsonc | 20 ++ .../plugins/marketplace-test/package.json | 24 +-- .../plugins/marketplace-test/src/index.ts | 36 ---- .../src/{sandbox-entry.ts => plugin.ts} | 15 +- .../sandboxed-test/emdash-plugin.jsonc | 28 +++ packages/plugins/sandboxed-test/package.json | 24 +-- packages/plugins/sandboxed-test/src/index.ts | 31 ---- .../src/{sandbox-entry.ts => plugin.ts} | 0 .../webhook-notifier/emdash-plugin.jsonc | 27 +++ .../plugins/webhook-notifier/package.json | 33 +--- .../plugins/webhook-notifier/src/index.ts | 52 ------ .../src/{sandbox-entry.ts => plugin.ts} | 0 .../schemas/emdash-plugin.schema.json | 171 ++++++++++++++---- packages/registry-cli/src/bundle/api.ts | 9 +- packages/registry-cli/src/manifest/schema.ts | 87 +++++++++ .../registry-cli/src/manifest/translate.ts | 21 +++ pnpm-lock.yaml | 15 +- pnpm-workspace.yaml | 7 +- 28 files changed, 490 insertions(+), 425 deletions(-) create mode 100644 packages/plugins/atproto/emdash-plugin.jsonc delete mode 100644 packages/plugins/atproto/src/index.ts rename packages/plugins/atproto/src/{sandbox-entry.ts => plugin.ts} (100%) rename packages/plugins/atproto/tests/{sandbox-entry.test.ts => plugin-runtime.test.ts} (91%) create mode 100644 packages/plugins/audit-log/emdash-plugin.jsonc delete mode 100644 packages/plugins/audit-log/src/index.ts rename packages/plugins/audit-log/src/{sandbox-entry.ts => plugin.ts} (100%) create mode 100644 packages/plugins/marketplace-test/emdash-plugin.jsonc delete mode 100644 packages/plugins/marketplace-test/src/index.ts rename packages/plugins/marketplace-test/src/{sandbox-entry.ts => plugin.ts} (64%) create mode 100644 packages/plugins/sandboxed-test/emdash-plugin.jsonc delete mode 100644 packages/plugins/sandboxed-test/src/index.ts rename packages/plugins/sandboxed-test/src/{sandbox-entry.ts => plugin.ts} (100%) create mode 100644 packages/plugins/webhook-notifier/emdash-plugin.jsonc delete mode 100644 packages/plugins/webhook-notifier/src/index.ts rename packages/plugins/webhook-notifier/src/{sandbox-entry.ts => plugin.ts} (100%) diff --git a/packages/plugins/atproto/emdash-plugin.jsonc b/packages/plugins/atproto/emdash-plugin.jsonc new file mode 100644 index 000000000..42ee5fe81 --- /dev/null +++ b/packages/plugins/atproto/emdash-plugin.jsonc @@ -0,0 +1,26 @@ +{ + "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + + "slug": "atproto", + "version": "0.1.3", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Syndicates published content to the AT Protocol network via standard.site lexicons, with optional Bluesky cross-posting.", + + // Trust contract. Needs unrestricted outbound HTTP because the + // publisher's PDS host varies per user — there's no fixed + // allow-list. Reads content for syndication. + "capabilities": ["content:read", "network:request:unrestricted"], + "allowedHosts": [], + "storage": { + "records": { "indexes": ["contentId", "status", "lastSyncedAt"] }, + }, + + "admin": { + "pages": [{ "path": "/status", "label": "AT Protocol", "icon": "globe" }], + "widgets": [{ "id": "sync-status", "title": "AT Protocol", "size": "third" }], + }, +} diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index e0ea0c9f8..5d5dc6bd3 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -3,18 +3,11 @@ "version": "0.1.3", "description": "AT Protocol / standard.site syndication plugin for EmDash CMS", "type": "module", - "main": "dist/index.mjs", - "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./sandbox": "./dist/sandbox-entry.mjs" + "private": true, + "scripts": { + "test": "vitest run", + "typecheck": "tsgo --noEmit" }, - "files": [ - "dist", - "src" - ], "keywords": [ "emdash", "cms", @@ -27,22 +20,17 @@ ], "author": "Matt Kane", "license": "MIT", - "peerDependencies": { - "emdash": "workspace:>=0.10.0" + "dependencies": { + "emdash": "workspace:*" }, "devDependencies": { - "tsdown": "catalog:", + "jsonc-parser": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, - "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "test": "vitest run", - "typecheck": "tsgo --noEmit" - }, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/plugins/atproto" - }, - "dependencies": {} + } } diff --git a/packages/plugins/atproto/src/index.ts b/packages/plugins/atproto/src/index.ts deleted file mode 100644 index 9555e9091..000000000 --- a/packages/plugins/atproto/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * AT Protocol / standard.site Plugin for EmDash CMS - * - * Syndicates published content to the AT Protocol network using the - * standard.site lexicons, with optional cross-posting to Bluesky. - * - * Features: - * - Creates site.standard.publication record (one per site) - * - Creates site.standard.document records on publish - * - Optional Bluesky cross-post with link card - * - Automatic injection via page:metadata - * - Sync status tracking in plugin storage - * - * Designed for sandboxed execution: - * - All HTTP via ctx.http.fetch() - * - Block Kit admin UI (no React components) - * - Capabilities: content:read, network:request:unrestricted - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -// ── Descriptor ────────────────────────────────────────────────── - -/** - * Create the AT Protocol plugin descriptor. - * Import this in your astro.config.mjs / live.config.ts. - */ -export function atprotoPlugin(): PluginDescriptor { - return { - id: "atproto", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-atproto/sandbox", - capabilities: ["content:read", "network:request:unrestricted"], - storage: { - records: { indexes: ["contentId", "status", "lastSyncedAt"] }, - }, - // Block Kit admin pages (no adminEntry needed -- sandboxed) - adminPages: [{ path: "/status", label: "AT Protocol", icon: "globe" }], - adminWidgets: [{ id: "sync-status", title: "AT Protocol", size: "third" }], - }; -} diff --git a/packages/plugins/atproto/src/sandbox-entry.ts b/packages/plugins/atproto/src/plugin.ts similarity index 100% rename from packages/plugins/atproto/src/sandbox-entry.ts rename to packages/plugins/atproto/src/plugin.ts diff --git a/packages/plugins/atproto/tests/sandbox-entry.test.ts b/packages/plugins/atproto/tests/plugin-runtime.test.ts similarity index 91% rename from packages/plugins/atproto/tests/sandbox-entry.test.ts rename to packages/plugins/atproto/tests/plugin-runtime.test.ts index 907be5af1..173dcbb21 100644 --- a/packages/plugins/atproto/tests/sandbox-entry.test.ts +++ b/packages/plugins/atproto/tests/plugin-runtime.test.ts @@ -29,7 +29,7 @@ function createCtx() { describe("sandbox hooks", () => { it("does not create syndication records from afterSave when published content has not been synced", async () => { - const { default: plugin } = await import("../src/sandbox-entry.js"); + const { default: plugin } = await import("../src/plugin.js"); const ctx = createCtx(); const handler = (plugin as any).hooks["content:afterSave"].handler; @@ -53,7 +53,7 @@ describe("sandbox hooks", () => { }); it("does not syndicate pages by default", async () => { - const { default: plugin } = await import("../src/sandbox-entry.js"); + const { default: plugin } = await import("../src/plugin.js"); const ctx = createCtx(); const handler = (plugin as any).hooks["content:afterPublish"].handler; @@ -76,7 +76,7 @@ describe("sandbox hooks", () => { }); it("does not expose standard.site metadata for pages by default", async () => { - const { default: plugin } = await import("../src/sandbox-entry.js"); + const { default: plugin } = await import("../src/plugin.js"); const ctx = createCtx(); ctx.storage.records.get.mockResolvedValueOnce({ atUri: "at://did:example/site.standard.document/abc", diff --git a/packages/plugins/atproto/tests/plugin.test.ts b/packages/plugins/atproto/tests/plugin.test.ts index dd45bc7dc..927959290 100644 --- a/packages/plugins/atproto/tests/plugin.test.ts +++ b/packages/plugins/atproto/tests/plugin.test.ts @@ -1,44 +1,75 @@ -import { describe, it, expect } from "vitest"; +/** + * Manifest assertions for the AT Protocol plugin. + * + * The redesigned sandboxed-plugin layout puts identity, trust contract, + * and admin surface in `emdash-plugin.jsonc` (the source of truth) and + * leaves `src/plugin.ts` for runtime code only. This test snapshots the + * manifest's structural shape so a refactor can't silently change the + * published trust contract or admin surface. + */ + +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { parse as parseJsonc } from "jsonc-parser"; +import { describe, expect, it } from "vitest"; import { version } from "../package.json"; -import { atprotoPlugin } from "../src/index.js"; - -describe("atprotoPlugin descriptor", () => { - it("returns a valid PluginDescriptor", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.id).toBe("atproto"); - expect(descriptor.version).toBe(version); - expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-atproto/sandbox"); - expect(descriptor.adminPages).toHaveLength(1); - expect(descriptor.adminWidgets).toHaveLength(1); + +const MANIFEST_PATH = fileURLToPath(new URL("../emdash-plugin.jsonc", import.meta.url)); + +interface Manifest { + slug: string; + version: string; + publisher: string; + capabilities: string[]; + allowedHosts: string[]; + storage: Record; + admin: { + pages: Array<{ path: string; label: string; icon?: string }>; + widgets: Array<{ id: string; title?: string; size?: string }>; + }; +} + +async function loadManifest(): Promise { + const source = await readFile(MANIFEST_PATH, "utf8"); + const errors: import("jsonc-parser").ParseError[] = []; + const value: unknown = parseJsonc(source, errors, { + allowTrailingComma: true, + disallowComments: false, }); + if (errors.length > 0) { + throw new Error(`Manifest parse failed: ${JSON.stringify(errors)}`); + } + return value as Manifest; +} - it("uses standard format", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.format).toBe("standard"); +describe("atproto plugin manifest", () => { + it("declares the expected identity", async () => { + const manifest = await loadManifest(); + expect(manifest.slug).toBe("atproto"); + expect(manifest.version).toBe(version); }); - it("declares required capabilities", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.capabilities).toContain("content:read"); - expect(descriptor.capabilities).toContain("network:request:unrestricted"); + it("declares the required capabilities", async () => { + const manifest = await loadManifest(); + expect(manifest.capabilities).toContain("content:read"); + expect(manifest.capabilities).toContain("network:request:unrestricted"); }); - it("declares the storage used by the sandbox implementation", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.storage).toHaveProperty("records"); - expect(descriptor.storage!.records!.indexes).toContain("contentId"); - expect(descriptor.storage!.records!.indexes).toContain("status"); - expect(descriptor.storage!.records!.indexes).toContain("lastSyncedAt"); + it("declares the storage used by the runtime", async () => { + const manifest = await loadManifest(); + expect(manifest.storage).toHaveProperty("records"); + expect(manifest.storage.records.indexes).toContain("contentId"); + expect(manifest.storage.records.indexes).toContain("status"); + expect(manifest.storage.records.indexes).toContain("lastSyncedAt"); }); - it("exposes an admin status page and widget", () => { - const descriptor = atprotoPlugin(); - expect(descriptor.adminPages).toEqual([ - { path: "/status", label: "AT Protocol", icon: "globe" }, - ]); - expect(descriptor.adminWidgets).toEqual([ - { id: "sync-status", title: "AT Protocol", size: "third" }, - ]); + it("declares the admin pages and widgets", async () => { + const manifest = await loadManifest(); + expect(manifest.admin.pages).toHaveLength(1); + expect(manifest.admin.pages[0]?.path).toBe("/status"); + expect(manifest.admin.widgets).toHaveLength(1); + expect(manifest.admin.widgets[0]?.id).toBe("sync-status"); }); }); diff --git a/packages/plugins/audit-log/emdash-plugin.jsonc b/packages/plugins/audit-log/emdash-plugin.jsonc new file mode 100644 index 000000000..e4d986f90 --- /dev/null +++ b/packages/plugins/audit-log/emdash-plugin.jsonc @@ -0,0 +1,26 @@ +{ + "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + + "slug": "audit-log", + "version": "0.1.3", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Tracks content and media changes (create / update / delete) for compliance and debugging.", + + // Trust contract. Reads content to capture before-state in + // update audit entries; declares the `entries` storage collection + // where audit log lives. No outbound network. + "capabilities": ["content:read"], + "allowedHosts": [], + "storage": { + "entries": { "indexes": ["timestamp", "action", "resourceType", "collection"] }, + }, + + "admin": { + "pages": [{ "path": "/history", "label": "Audit History", "icon": "history" }], + "widgets": [{ "id": "recent-activity", "title": "Recent Activity", "size": "half" }], + }, +} diff --git a/packages/plugins/audit-log/package.json b/packages/plugins/audit-log/package.json index bc5ec887a..efb9841cd 100644 --- a/packages/plugins/audit-log/package.json +++ b/packages/plugins/audit-log/package.json @@ -3,40 +3,19 @@ "version": "0.1.3", "description": "Audit logging plugin for EmDash CMS - tracks content changes", "type": "module", - "main": "dist/index.mjs", - "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./sandbox": "./dist/sandbox-entry.mjs" + "private": true, + "scripts": { + "typecheck": "tsgo --noEmit" }, - "files": [ - "dist" - ], - "keywords": [ - "emdash", - "cms", - "plugin", - "audit", - "logging", - "history" - ], + "keywords": ["emdash", "cms", "plugin", "audit", "logging", "history"], "author": "Matt Kane", "license": "MIT", - "peerDependencies": { - "emdash": "workspace:>=0.10.0" + "dependencies": { + "emdash": "workspace:*" }, "devDependencies": { - "tsdown": "catalog:", "typescript": "catalog:" }, - "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", - "typecheck": "tsgo --noEmit" - }, - "optionalDependencies": {}, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/audit-log/src/index.ts b/packages/plugins/audit-log/src/index.ts deleted file mode 100644 index 717ab0d78..000000000 --- a/packages/plugins/audit-log/src/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Audit Log Plugin for EmDash CMS - * - * Tracks all content and media changes for compliance and debugging. - * - * Features: - * - Logs create, update, delete operations - * - Tracks before/after state for updates - * - Records user information (when available) - * - Provides admin UI for viewing audit history - * - Configurable retention period (admin settings) - * - Uses plugin storage for persistent audit trail - * - * Demonstrates: - * - Plugin storage with indexes and queries - * - Admin-configurable settings schema - * - Lifecycle hooks (install, activate, deactivate, uninstall) - * - content:afterDelete hook - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -export interface AuditEntry { - timestamp: string; - action: "create" | "update" | "delete" | "media:upload" | "media:delete"; - collection?: string; - resourceId: string; - resourceType: "content" | "media"; - userId?: string; - changes?: { - before?: Record; - after?: Record; - }; - metadata?: Record; -} - -/** - * Create the audit log plugin descriptor - */ -export function auditLogPlugin(): PluginDescriptor { - return { - id: "audit-log", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-audit-log/sandbox", - capabilities: ["content:read"], - storage: { - entries: { indexes: ["timestamp", "action", "resourceType", "collection"] }, - }, - adminPages: [{ path: "/history", label: "Audit History", icon: "history" }], - adminWidgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }], - }; -} diff --git a/packages/plugins/audit-log/src/sandbox-entry.ts b/packages/plugins/audit-log/src/plugin.ts similarity index 100% rename from packages/plugins/audit-log/src/sandbox-entry.ts rename to packages/plugins/audit-log/src/plugin.ts diff --git a/packages/plugins/marketplace-test/emdash-plugin.jsonc b/packages/plugins/marketplace-test/emdash-plugin.jsonc new file mode 100644 index 000000000..7bfbc98e5 --- /dev/null +++ b/packages/plugins/marketplace-test/emdash-plugin.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + + "slug": "marketplace-test", + "version": "0.1.2", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Test plugin for end-to-end registry publishing and audit workflow testing.", + + // Trust contract. Reads and writes content; declared `events` + // storage collection. No outbound network. + "capabilities": ["content:read", "content:write"], + "allowedHosts": [], + "storage": { + "events": { "indexes": ["timestamp", "type"] }, + }, +} diff --git a/packages/plugins/marketplace-test/package.json b/packages/plugins/marketplace-test/package.json index ab7c23ad0..c501ac5c1 100644 --- a/packages/plugins/marketplace-test/package.json +++ b/packages/plugins/marketplace-test/package.json @@ -2,38 +2,18 @@ "name": "@emdash-cms/plugin-marketplace-test", "private": true, "version": "0.1.2", - "description": "Test plugin for end-to-end marketplace publishing and audit workflow testing", + "description": "Test plugin for end-to-end registry publishing and audit workflow testing", "type": "module", - "main": "dist/index.mjs", - "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./sandbox": "./dist/sandbox-entry.mjs" - }, - "files": [ - "dist" - ], "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", "typecheck": "tsgo --noEmit" }, - "keywords": [ - "emdash", - "cms", - "plugin", - "test", - "marketplace" - ], + "keywords": ["emdash", "cms", "plugin", "test", "marketplace"], "author": "Matt Kane", "license": "MIT", "dependencies": { "emdash": "workspace:*" }, "devDependencies": { - "tsdown": "catalog:", "typescript": "catalog:" }, "repository": { diff --git a/packages/plugins/marketplace-test/src/index.ts b/packages/plugins/marketplace-test/src/index.ts deleted file mode 100644 index 99f0e4179..000000000 --- a/packages/plugins/marketplace-test/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Marketplace Test Plugin for EmDash CMS - * - * A self-contained plugin designed for end-to-end testing of the marketplace - * publish → audit → approval pipeline. Includes: - * - Backend sandbox code (content:beforeSave hook) - * - Icon and screenshot assets - * - Full manifest with capabilities - * - * Usage: - * emdash plugin bundle --dir packages/plugins/marketplace-test - * emdash plugin publish dist/marketplace-test-0.1.0.tar.gz --registry - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -/** - * Plugin factory -- returns a descriptor for the integration. - */ -export function marketplaceTestPlugin(): PluginDescriptor { - return { - id: "marketplace-test", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-marketplace-test/sandbox", - capabilities: ["content:read", "content:write"], - allowedHosts: [], - storage: { - events: { indexes: ["timestamp", "type"] }, - }, - }; -} - -export default marketplaceTestPlugin; diff --git a/packages/plugins/marketplace-test/src/sandbox-entry.ts b/packages/plugins/marketplace-test/src/plugin.ts similarity index 64% rename from packages/plugins/marketplace-test/src/sandbox-entry.ts rename to packages/plugins/marketplace-test/src/plugin.ts index 38be1b135..eceb80fa0 100644 --- a/packages/plugins/marketplace-test/src/sandbox-entry.ts +++ b/packages/plugins/marketplace-test/src/plugin.ts @@ -1,8 +1,14 @@ /** - * Sandbox Entry Point + * Marketplace Test Plugin for EmDash CMS — sandbox entry. * - * Canonical plugin implementation using the standard format. - * Runs in both trusted (in-process) and sandboxed (isolate) modes. + * Self-contained plugin for end-to-end testing of the registry publish + * → audit → install pipeline. Exercises the three primitives a real + * sandboxed plugin uses: a hook (`content:beforeSave`), routes + * (`ping`, `events`), and a storage collection (`events`). + * + * Identity (id, version), the trust contract (capabilities, + * allowedHosts, storage), and the rest of the metadata live in + * `emdash-plugin.jsonc`. This file holds runtime behaviour only. */ import { definePlugin } from "emdash"; @@ -23,7 +29,8 @@ export default definePlugin({ isNew: event.isNew, }); - // Record execution in storage + // Record execution in storage so the registry's install + // audit can verify the hook actually ran post-install. await ctx.storage.events.put(`hook-${Date.now()}`, { timestamp: new Date().toISOString(), type: "content:beforeSave", diff --git a/packages/plugins/sandboxed-test/emdash-plugin.jsonc b/packages/plugins/sandboxed-test/emdash-plugin.jsonc new file mode 100644 index 000000000..9c696f6ae --- /dev/null +++ b/packages/plugins/sandboxed-test/emdash-plugin.jsonc @@ -0,0 +1,28 @@ +{ + "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + + "slug": "sandboxed-test", + "version": "0.0.3", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Test plugin exercising sandboxed-plugin enforcement and feature surface.", + + // Trust contract. Reads content + a single declared storage + // collection. Outbound HTTP restricted to httpbin.org so the + // adversarial-fetch tests have a real allowed host to contrast + // with the disallowed ones. + "capabilities": ["content:read", "network:request"], + "allowedHosts": ["httpbin.org"], + "storage": { + "events": { "indexes": ["timestamp", "type"] }, + }, + + // Admin surface — rendered by the `admin` route handler via Block Kit. + "admin": { + "pages": [{ "path": "/sandbox", "label": "Sandbox Tests", "icon": "shield" }], + "widgets": [{ "id": "sandbox-status", "title": "Sandbox Status", "size": "half" }], + }, +} diff --git a/packages/plugins/sandboxed-test/package.json b/packages/plugins/sandboxed-test/package.json index 2bfecaa51..2d7403d1d 100644 --- a/packages/plugins/sandboxed-test/package.json +++ b/packages/plugins/sandboxed-test/package.json @@ -4,40 +4,18 @@ "version": "0.0.3", "description": "Test plugin for sandboxed plugin system", "type": "module", - "main": "dist/index.mjs", - "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./sandbox": "./dist/sandbox-entry.mjs" - }, - "files": [ - "dist" - ], "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", "typecheck": "tsgo --noEmit" }, - "keywords": [ - "emdash", - "cms", - "plugin", - "test", - "sandbox" - ], + "keywords": ["emdash", "cms", "plugin", "test", "sandbox"], "author": "Matt Kane", "license": "MIT", "dependencies": { "emdash": "workspace:*" }, "devDependencies": { - "tsdown": "catalog:", "typescript": "catalog:" }, - "peerDependencies": {}, - "optionalDependencies": {}, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/sandboxed-test/src/index.ts b/packages/plugins/sandboxed-test/src/index.ts deleted file mode 100644 index d08f7d850..000000000 --- a/packages/plugins/sandboxed-test/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Sandboxed Test Plugin for EmDash CMS - * - * Tests the sandboxed plugin system. Designed to run in an isolated - * V8 isolate via Worker Loader. Admin UI uses Block Kit. - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -/** - * Plugin factory - returns a descriptor for the integration - */ -export function sandboxedTestPlugin(): PluginDescriptor { - return { - id: "sandboxed-test", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-sandboxed-test/sandbox", - - adminPages: [{ path: "/sandbox", label: "Sandbox Tests", icon: "shield" }], - adminWidgets: [{ id: "sandbox-status", title: "Sandbox Status", size: "half" }], - - capabilities: ["content:read", "network:request"], - allowedHosts: ["httpbin.org"], - storage: { - events: { indexes: ["timestamp", "type"] }, - }, - }; -} diff --git a/packages/plugins/sandboxed-test/src/sandbox-entry.ts b/packages/plugins/sandboxed-test/src/plugin.ts similarity index 100% rename from packages/plugins/sandboxed-test/src/sandbox-entry.ts rename to packages/plugins/sandboxed-test/src/plugin.ts diff --git a/packages/plugins/webhook-notifier/emdash-plugin.jsonc b/packages/plugins/webhook-notifier/emdash-plugin.jsonc new file mode 100644 index 000000000..ef10c1e70 --- /dev/null +++ b/packages/plugins/webhook-notifier/emdash-plugin.jsonc @@ -0,0 +1,27 @@ +{ + "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + + "slug": "webhook-notifier", + "version": "0.1.3", + "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com + + "license": "MIT", + "author": { "name": "Matt Kane" }, + "security": { "url": "https://github.com/emdash-cms/emdash/security/advisories/new" }, + "description": "Posts to user-configured external URLs when content or media changes.", + + // Trust contract. Outbound HTTP is unrestricted because the + // webhook URLs are user-supplied at runtime — there's no fixed + // allow-list we could pin at publish time. Declares the + // `deliveries` storage collection for audit + retry state. + "capabilities": ["network:request:unrestricted"], + "allowedHosts": [], + "storage": { + "deliveries": { "indexes": ["timestamp", "webhookUrl", "status"] }, + }, + + "admin": { + "pages": [{ "path": "/settings", "label": "Webhook Settings", "icon": "send" }], + "widgets": [{ "id": "status", "title": "Webhooks", "size": "third" }], + }, +} diff --git a/packages/plugins/webhook-notifier/package.json b/packages/plugins/webhook-notifier/package.json index 28e8e9943..3cdd96feb 100644 --- a/packages/plugins/webhook-notifier/package.json +++ b/packages/plugins/webhook-notifier/package.json @@ -3,40 +3,19 @@ "version": "0.1.3", "description": "Webhook notification plugin for EmDash CMS - posts to external URLs on content changes", "type": "module", - "main": "dist/index.mjs", - "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./sandbox": "./dist/sandbox-entry.mjs" + "private": true, + "scripts": { + "typecheck": "tsgo --noEmit" }, - "files": [ - "dist" - ], - "keywords": [ - "emdash", - "cms", - "plugin", - "webhook", - "notifications", - "integration" - ], + "keywords": ["emdash", "cms", "plugin", "webhook", "notifications", "integration"], "author": "Matt Kane", "license": "MIT", - "peerDependencies": { - "emdash": "workspace:>=0.10.0" + "dependencies": { + "emdash": "workspace:*" }, "devDependencies": { - "tsdown": "catalog:", "typescript": "catalog:" }, - "scripts": { - "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", - "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", - "typecheck": "tsgo --noEmit" - }, - "optionalDependencies": {}, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/webhook-notifier/src/index.ts b/packages/plugins/webhook-notifier/src/index.ts deleted file mode 100644 index a045aeea3..000000000 --- a/packages/plugins/webhook-notifier/src/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Webhook Notifier Plugin for EmDash CMS - * - * Posts to external URLs when content changes occur. - * - * Features: - * - Configurable webhook URLs (admin settings) - * - Secret token for authentication (encrypted) - * - Retry logic with exponential backoff - * - Event filtering by collection and action - * - Manual trigger via API route - * - * Demonstrates: - * - network:request:unrestricted capability (unrestricted outbound for user-configured URLs) - * - settings.secret() for encrypted tokens - * - apiRoutes for custom endpoints - * - content:afterDelete hook - * - Hook dependencies (runs after audit-log) - * - errorPolicy: "continue" (don't block save on webhook failure) - */ - -import type { PluginDescriptor } from "emdash"; - -import { version } from "../package.json"; - -export interface WebhookPayload { - event: "content:create" | "content:update" | "content:delete" | "media:upload"; - timestamp: string; - collection?: string; - resourceId: string; - resourceType: "content" | "media"; - data?: Record; - metadata?: Record; -} - -/** - * Create the webhook notifier plugin descriptor - */ -export function webhookNotifierPlugin(): PluginDescriptor { - return { - id: "webhook-notifier", - version, - format: "standard", - entrypoint: "@emdash-cms/plugin-webhook-notifier/sandbox", - capabilities: ["network:request:unrestricted"], - storage: { - deliveries: { indexes: ["timestamp", "webhookUrl", "status"] }, - }, - adminPages: [{ path: "/settings", label: "Webhook Settings", icon: "send" }], - adminWidgets: [{ id: "status", title: "Webhooks", size: "third" }], - }; -} diff --git a/packages/plugins/webhook-notifier/src/sandbox-entry.ts b/packages/plugins/webhook-notifier/src/plugin.ts similarity index 100% rename from packages/plugins/webhook-notifier/src/sandbox-entry.ts rename to packages/plugins/webhook-notifier/src/plugin.ts diff --git a/packages/registry-cli/schemas/emdash-plugin.schema.json b/packages/registry-cli/schemas/emdash-plugin.schema.json index b1c29319f..4ba7adaad 100644 --- a/packages/registry-cli/schemas/emdash-plugin.schema.json +++ b/packages/registry-cli/schemas/emdash-plugin.schema.json @@ -29,29 +29,32 @@ "storage": { "$ref": "#/$defs/__schema9" }, - "author": { + "admin": { "$ref": "#/$defs/__schema16" }, + "author": { + "$ref": "#/$defs/__schema27" + }, "authors": { - "$ref": "#/$defs/__schema21" + "$ref": "#/$defs/__schema32" }, "security": { - "$ref": "#/$defs/__schema22" + "$ref": "#/$defs/__schema33" }, "securityContacts": { - "$ref": "#/$defs/__schema26" + "$ref": "#/$defs/__schema37" }, "name": { - "$ref": "#/$defs/__schema27" + "$ref": "#/$defs/__schema38" }, "description": { - "$ref": "#/$defs/__schema28" + "$ref": "#/$defs/__schema39" }, "keywords": { - "$ref": "#/$defs/__schema29" + "$ref": "#/$defs/__schema40" }, "repo": { - "$ref": "#/$defs/__schema31" + "$ref": "#/$defs/__schema42" } }, "required": [ @@ -230,19 +233,123 @@ "minLength": 1 }, "__schema16": { - "$ref": "#/$defs/__schema17" + "type": "object", + "properties": { + "pages": { + "$ref": "#/$defs/__schema17" + }, + "widgets": { + "$ref": "#/$defs/__schema22" + } + }, + "additionalProperties": false, + "title": "Admin surface", + "description": "Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime." }, "__schema17": { + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema18" + } + }, + "__schema18": { + "type": "object", + "properties": { + "path": { + "$ref": "#/$defs/__schema19" + }, + "label": { + "$ref": "#/$defs/__schema20" + }, + "icon": { + "$ref": "#/$defs/__schema21" + } + }, + "required": [ + "path", + "label" + ], + "additionalProperties": false, + "title": "Admin page", + "description": "A single admin page declaration. The plugin's `admin` route handler is responsible for rendering Block Kit content for this path." + }, + "__schema19": { + "type": "string", + "minLength": 2, + "maxLength": 128, + "pattern": "^\\/[a-z0-9][a-z0-9/_-]*$" + }, + "__schema20": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "__schema21": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "__schema22": { + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema23" + } + }, + "__schema23": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema24" + }, + "title": { + "$ref": "#/$defs/__schema25" + }, + "size": { + "$ref": "#/$defs/__schema26" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "Admin widget", + "description": "A single dashboard widget declaration." + }, + "__schema24": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "__schema25": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "__schema26": { + "type": "string", + "enum": [ + "full", + "half", + "third" + ] + }, + "__schema27": { + "$ref": "#/$defs/__schema28" + }, + "__schema28": { "type": "object", "properties": { "name": { - "$ref": "#/$defs/__schema18" + "$ref": "#/$defs/__schema29" }, "url": { - "$ref": "#/$defs/__schema19" + "$ref": "#/$defs/__schema30" }, "email": { - "$ref": "#/$defs/__schema20" + "$ref": "#/$defs/__schema31" } }, "required": [ @@ -252,104 +359,104 @@ "title": "Author", "description": "A single author entry. Mirrors the lexicon's author shape." }, - "__schema18": { + "__schema29": { "type": "string", "minLength": 1, "maxLength": 256, "description": "Display name." }, - "__schema19": { + "__schema30": { "type": "string", "maxLength": 1024, "format": "uri", "description": "Author's homepage or profile URL. Either this or `email` is recommended." }, - "__schema20": { + "__schema31": { "type": "string", "maxLength": 256, "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "description": "Author's contact email. Either this or `url` is recommended." }, - "__schema21": { + "__schema32": { "minItems": 1, "maxItems": 32, "type": "array", "items": { - "$ref": "#/$defs/__schema17" + "$ref": "#/$defs/__schema28" }, "title": "Authors (multiple)", "description": "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one." }, - "__schema22": { - "$ref": "#/$defs/__schema23" + "__schema33": { + "$ref": "#/$defs/__schema34" }, - "__schema23": { + "__schema34": { "type": "object", "properties": { "url": { - "$ref": "#/$defs/__schema24" + "$ref": "#/$defs/__schema35" }, "email": { - "$ref": "#/$defs/__schema25" + "$ref": "#/$defs/__schema36" } }, "additionalProperties": false, "title": "Security contact", "description": "A single security contact. At least one of `url` or `email` must be present." }, - "__schema24": { + "__schema35": { "type": "string", "maxLength": 1024, "format": "uri", "description": "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required." }, - "__schema25": { + "__schema36": { "type": "string", "maxLength": 256, "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "description": "Security contact email. Either this or `url` is required." }, - "__schema26": { + "__schema37": { "minItems": 1, "maxItems": 8, "type": "array", "items": { - "$ref": "#/$defs/__schema23" + "$ref": "#/$defs/__schema34" }, "title": "Security contacts (multiple)", "description": "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one." }, - "__schema27": { + "__schema38": { "type": "string", "minLength": 1, "maxLength": 1024, "title": "Display name", "description": "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted." }, - "__schema28": { + "__schema39": { "type": "string", "minLength": 1, "maxLength": 1024, "title": "Description", "description": "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists." }, - "__schema29": { + "__schema40": { "maxItems": 5, "type": "array", "items": { - "$ref": "#/$defs/__schema30" + "$ref": "#/$defs/__schema41" }, "title": "Keywords", "description": "Search keywords (<= 5 entries, FAIR convention)." }, - "__schema30": { + "__schema41": { "type": "string", "minLength": 1, "maxLength": 128 }, - "__schema31": { + "__schema42": { "type": "string", "maxLength": 1024, "format": "uri", diff --git a/packages/registry-cli/src/bundle/api.ts b/packages/registry-cli/src/bundle/api.ts index 21e5b9637..587e9c9c5 100644 --- a/packages/registry-cli/src/bundle/api.ts +++ b/packages/registry-cli/src/bundle/api.ts @@ -471,7 +471,14 @@ async function assembleResolvedPlugin(ctx: AssembleContext): Promise; + widgets: Array<{ id: string; title?: string; size?: "full" | "half" | "third" }>; +} + export interface NormalisedManifest { // Identity (required). slug: string; @@ -36,6 +46,13 @@ export interface NormalisedManifest { capabilities: PluginCapability[]; allowedHosts: string[]; storage: PluginStorageConfig; + + /** + * Admin surface. Always present in the normalised form (with + * empty arrays when the manifest didn't declare anything) so the + * bundle layer can pass it through without conditional handling. + */ + admin: NormalisedAdmin; } /** @@ -74,6 +91,10 @@ export function normaliseManifest(manifest: Manifest): NormalisedManifest { // constraint. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- schema-enforced narrowing storage: manifest.storage as PluginStorageConfig, + admin: { + pages: manifest.admin?.pages ?? [], + widgets: manifest.admin?.widgets ?? [], + }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65bc69b7b..7f1dbacb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1674,12 +1674,15 @@ importers: packages/plugins/atproto: dependencies: emdash: - specifier: workspace:>=0.10.0 + specifier: workspace:* version: link:../../core devDependencies: - tsdown: + jsonc-parser: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 3.3.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 vitest: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -1793,9 +1796,6 @@ importers: specifier: workspace:* version: link:../../core devDependencies: - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1806,9 +1806,6 @@ importers: specifier: workspace:* version: link:../../core devDependencies: - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a3b2c488..3f90a3b0c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -82,10 +82,10 @@ catalog: "@types/node": 24.10.13 "@types/react": 19.2.14 "@types/react-dom": 19.2.3 - jsonc-parser: ^3.3.1 astro: ^6.0.1 astro-iconset: ^0.0.4 better-sqlite3: ^12.8.0 + jsonc-parser: ^3.3.1 publint: 0.3.17 react: 19.2.4 react-dom: 19.2.4 @@ -94,9 +94,4 @@ catalog: vite: ^8.0.11 vitest: ^4.1.5 wrangler: ^4.83.0 - # Catalog-pin Zod so the whole workspace dedupes on a single instance. - # Zod 4 embeds the version in the type, so even ^4.3.6 vs ^4.4.1 produce - # structurally incompatible ZodType across packages that mix astro/zod - # and emdash's zod (e.g. trusted plugins like @emdash-cms/plugin-forms - # importing 'z' from astro/zod and passing schemas to definePlugin). zod: ^4.4.1 From 59396340ffcdaed98226ecbdc317cdc88ac72e05 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 15 May 2026 07:13:09 +0100 Subject: [PATCH 05/16] feat(registry-cli): add localPlugin(dir) dev helper + wire demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final piece of the sandboxed-plugin redesign (#1028b). Closes the gap the plugin migrations opened — demos that previously imported `auditLogPlugin()` / `webhookNotifierPlugin()` factories now consume the plugins through their source directories. New subpath `@emdash-cms/registry-cli/dev` exports `localPlugin(dir)`, which: - Reads `/emdash-plugin.jsonc` via the same loader the CLI uses. - Confirms `/src/plugin.ts` exists. - Resolves the manifest's publisher (handle → DID) so the descriptor is in canonical form. - Returns a PluginDescriptor-shaped object with `entrypoint` set to the absolute `file://` URL of `src/plugin.ts`. Vite resolves the URL through its standard fs path resolver — no build step needed. The descriptor carries id, version, capabilities, allowedHosts, storage, and (when declared) adminPages + adminWidgets from the manifest. Plugins that don't expose admin surface pass through without the optional fields, keeping the descriptor tidy. Demos updated: - demos/simple: auditLogPlugin() → localPlugin("../../packages/plugins/audit-log") - demos/plugins-demo: auditLog + webhookNotifier the same way - demos/cloudflare: webhookNotifier via localPlugin - infra/cache-demo, infra/blog-demo: same Trusted plugins (formsPlugin, embedsPlugin, apiTestPlugin) keep their factory-based imports — they're not on the new shape and aren't part of this redesign's scope. Errors surface as a structured LocalPluginError with codes: - MANIFEST_INVALID - PLUGIN_ENTRY_MISSING - PUBLISHER_UNRESOLVED Tests: 10 new (descriptor shape, error paths, admin pass-through). 259 total in the package. --- demos/cloudflare/astro.config.mjs | 6 +- demos/cloudflare/package.json | 5 +- demos/plugins-demo/astro.config.mjs | 20 +- demos/plugins-demo/package.json | 9 +- demos/simple/astro.config.mjs | 6 +- demos/simple/package.json | 5 +- infra/blog-demo/astro.config.mjs | 6 +- infra/blog-demo/package.json | 1 + infra/cache-demo/astro.config.mjs | 6 +- infra/cache-demo/package.json | 1 + packages/registry-cli/package.json | 4 + packages/registry-cli/src/dev.ts | 196 ++++++++++++++++++ .../tests/dev-local-plugin.test.ts | 155 ++++++++++++++ packages/registry-cli/tsdown.config.ts | 4 +- pnpm-lock.yaml | 25 ++- 15 files changed, 411 insertions(+), 38 deletions(-) create mode 100644 packages/registry-cli/src/dev.ts create mode 100644 packages/registry-cli/tests/dev-local-plugin.test.ts diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 3c7c731cc..11e866880 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -11,10 +11,12 @@ import { cloudflareStream, } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import { localPlugin } from "@emdash-cms/registry-cli/dev"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; +const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); + export default defineConfig({ output: "server", adapter: cloudflare({ @@ -74,7 +76,7 @@ export default defineConfig({ formsPlugin(), ], // Sandboxed plugins (run in isolated workers) - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], // Sandbox runner for Cloudflare sandboxRunner: sandbox(), // Plugin marketplace diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index f4992a14c..6e719d656 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -18,6 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/registry-cli": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "astro": "catalog:", @@ -33,7 +34,5 @@ }, "emdash": { "seed": "seed/seed.json" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } diff --git a/demos/plugins-demo/astro.config.mjs b/demos/plugins-demo/astro.config.mjs index d10c4d211..e9c77beaf 100644 --- a/demos/plugins-demo/astro.config.mjs +++ b/demos/plugins-demo/astro.config.mjs @@ -1,13 +1,19 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; import { apiTestPlugin } from "@emdash-cms/plugin-api-test"; -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; import { embedsPlugin } from "@emdash-cms/plugin-embeds"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import { localPlugin } from "@emdash-cms/registry-cli/dev"; import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { sqlite } from "emdash/db"; +// Sandboxed plugins are loaded directly from their source dirs via +// localPlugin(). The trusted plugins (api-test, embeds) keep their +// factory-based imports for now — they haven't migrated to the new +// shape yet. +const auditLog = await localPlugin("../../packages/plugins/audit-log"); +const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); + export default defineConfig({ output: "server", adapter: node({ @@ -22,18 +28,12 @@ export default defineConfig({ // Register plugins - order matters for hook execution! plugins: [ // 1. Audit log runs last (priority 200) to capture final state - // Settings (retention, data changes, excluded collections) are - // configured at runtime via the admin UI, not constructor options. - auditLogPlugin(), + auditLog, // 2. Webhook notifier sends events to external URLs - // Demonstrates: network:fetch:any, apiRoutes, settings.secret(), - // hook dependencies, errorPolicy: "continue" - // Webhook URL, collections, and actions are configured via admin settings. - webhookNotifierPlugin(), + webhookNotifier, // 3. Embeds plugin for YouTube, Vimeo, Twitter, etc. - // Components are auto-registered with PortableText embedsPlugin(), // 4. API Test plugin - exercises all v2 APIs diff --git a/demos/plugins-demo/package.json b/demos/plugins-demo/package.json index 75b430fe5..242357c04 100644 --- a/demos/plugins-demo/package.json +++ b/demos/plugins-demo/package.json @@ -13,10 +13,11 @@ "dependencies": { "@astrojs/node": "catalog:", "@astrojs/react": "catalog:", - "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-api-test": "workspace:*", - "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-embeds": "workspace:*", + "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/registry-cli": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "astro": "catalog:", @@ -27,7 +28,5 @@ }, "devDependencies": { "@types/node": "catalog:" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } \ No newline at end of file diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index 662168127..862efed7b 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -1,10 +1,12 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import { localPlugin } from "@emdash-cms/registry-cli/dev"; import { defineConfig, fontProviders } from "astro/config"; import emdash, { local } from "emdash/astro"; import { sqlite } from "emdash/db"; +const auditLog = await localPlugin("../../packages/plugins/audit-log"); + export default defineConfig({ output: "server", adapter: node({ @@ -22,7 +24,7 @@ export default defineConfig({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ], fonts: [ diff --git a/demos/simple/package.json b/demos/simple/package.json index a3cf8210c..5169bf61d 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -20,6 +20,7 @@ "@emdash-cms/plugin-atproto": "workspace:*", "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-color": "workspace:*", + "@emdash-cms/registry-cli": "workspace:*", "astro": "catalog:", "better-sqlite3": "catalog:", "emdash": "workspace:*", @@ -28,7 +29,5 @@ }, "devDependencies": { "@astrojs/check": "catalog:" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } diff --git a/infra/blog-demo/astro.config.mjs b/infra/blog-demo/astro.config.mjs index 00e20e7e8..e2dee2a15 100644 --- a/infra/blog-demo/astro.config.mjs +++ b/infra/blog-demo/astro.config.mjs @@ -2,8 +2,10 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import { localPlugin } from "@emdash-cms/registry-cli/dev"; import { defineConfig, fontProviders } from "astro/config"; + +const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); import emdash from "emdash/astro"; export default defineConfig({ @@ -19,7 +21,7 @@ export default defineConfig({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [formsPlugin()], - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], sandboxRunner: sandbox(), experimental: { registry: "https://registry.emdashcms.com", diff --git a/infra/blog-demo/package.json b/infra/blog-demo/package.json index 4499c2e33..ce01d3ce6 100644 --- a/infra/blog-demo/package.json +++ b/infra/blog-demo/package.json @@ -18,6 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/registry-cli": "workspace:*", "astro": "catalog:", "emdash": "workspace:*", "react": "catalog:", diff --git a/infra/cache-demo/astro.config.mjs b/infra/cache-demo/astro.config.mjs index 519ea9e80..14418c1a0 100644 --- a/infra/cache-demo/astro.config.mjs +++ b/infra/cache-demo/astro.config.mjs @@ -3,10 +3,12 @@ import { cacheCloudflare } from "@astrojs/cloudflare/cache"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import { localPlugin } from "@emdash-cms/registry-cli/dev"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; +const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); + export default defineConfig({ output: "server", adapter: cloudflare(), @@ -25,7 +27,7 @@ export default defineConfig({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [formsPlugin()], - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], sandboxRunner: sandbox(), marketplace: "https://marketplace.emdashcms.com", }), diff --git a/infra/cache-demo/package.json b/infra/cache-demo/package.json index a641e3e7c..05ae6ef01 100644 --- a/infra/cache-demo/package.json +++ b/infra/cache-demo/package.json @@ -18,6 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", + "@emdash-cms/registry-cli": "workspace:*", "astro": "https://pkg.pr.new/astro@94d342d", "emdash": "workspace:*", "react": "catalog:", diff --git a/packages/registry-cli/package.json b/packages/registry-cli/package.json index 3b5a585c3..c700cf318 100644 --- a/packages/registry-cli/package.json +++ b/packages/registry-cli/package.json @@ -8,6 +8,10 @@ ".": { "types": "./dist/api.d.mts", "default": "./dist/api.mjs" + }, + "./dev": { + "types": "./dist/dev.d.mts", + "default": "./dist/dev.mjs" } }, "bin": { diff --git a/packages/registry-cli/src/dev.ts b/packages/registry-cli/src/dev.ts new file mode 100644 index 000000000..02c52aefa --- /dev/null +++ b/packages/registry-cli/src/dev.ts @@ -0,0 +1,196 @@ +/** + * `@emdash-cms/registry-cli/dev` + * + * Local-development helper for consuming a sandboxed plugin directly + * from its source directory. Lets a site's `astro.config.mjs` reference + * a plugin via a directory path instead of importing a factory function: + * + * ```ts + * import { localPlugin } from "@emdash-cms/registry-cli/dev"; + * + * emdash({ + * plugins: [ + * await localPlugin("../packages/plugins/audit-log"), + * ], + * }); + * ``` + * + * The helper reads the plugin's `emdash-plugin.jsonc`, resolves the + * publisher's handle to a DID (when needed), and returns a + * `PluginDescriptor`-shaped object the integration's `plugins:` / + * `sandboxed:` arrays consume directly. Identity, the trust contract, + * and the admin surface come from the manifest; the runtime code is + * loaded by the integration's virtual-module loader from + * `/src/plugin.ts` (as an absolute file URL). + * + * Failure modes: + * + * - Manifest missing or invalid → `LocalPluginError(MANIFEST_INVALID)`. + * - `src/plugin.ts` missing → `LocalPluginError(PLUGIN_ENTRY_MISSING)`. + * - Publisher handle can't be resolved → `LocalPluginError(PUBLISHER_UNRESOLVED)`. + * + * This helper is for local dev only. Registry-installed plugins go + * through `emdash-registry bundle` + the runtime's install pipeline — + * they never touch this module. + */ + +import { access } from "node:fs/promises"; +import { join, resolve as resolvePath } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { isDid, isHandle } from "@atcute/lexicons/syntax"; + +import { ManifestError, loadManifest } from "./manifest/load.js"; +import { PublisherCheckError, resolveHandleToDid } from "./manifest/publisher.js"; +import { normaliseManifest, type NormalisedManifest } from "./manifest/translate.js"; + +export type LocalPluginErrorCode = + | "MANIFEST_INVALID" + | "PLUGIN_ENTRY_MISSING" + | "PUBLISHER_UNRESOLVED"; + +export class LocalPluginError extends Error { + override readonly name = "LocalPluginError"; + readonly code: LocalPluginErrorCode; + constructor(code: LocalPluginErrorCode, message: string) { + super(message); + this.code = code; + } +} + +/** + * Plugin descriptor produced by `localPlugin`. The shape mirrors the + * runtime's `PluginDescriptor` (from `emdash`'s astro integration) but + * we define the type locally so this module has zero runtime + * dependency on core — only the type contract matters. + * + * `entrypoint` is an absolute `file://` URL pointing at the plugin's + * `src/plugin.ts`. The integration's virtual-module loader passes + * that string to a dynamic `import()`, which Vite resolves via its + * normal filesystem-resolution path. + */ +export interface LocalPluginDescriptor { + id: string; + version: string; + format: "standard"; + entrypoint: string; + capabilities: string[]; + allowedHosts: string[]; + storage: Record< + string, + { indexes: Array; uniqueIndexes?: Array } + >; + adminPages?: Array<{ path: string; label: string; icon?: string }>; + adminWidgets?: Array<{ id: string; title?: string; size?: "full" | "half" | "third" }>; +} + +export interface LocalPluginOptions { + /** + * If true, suppresses the handle-to-DID resolution at load time. + * The descriptor carries whatever value the manifest's `publisher` + * field holds verbatim. Useful in tests; rarely needed in real + * code. + */ + skipPublisherResolution?: boolean; +} + +/** + * Load a sandboxed plugin from a local directory and return a + * descriptor the EmDash integration can consume. + * + * `dir` is resolved relative to the calling module's cwd (typically + * the site's project root). The directory must contain + * `emdash-plugin.jsonc` and `src/plugin.ts` — same layout + * `emdash-registry init` and `emdash-registry bundle` expect. + * + * The returned descriptor carries an absolute `file://` URL as its + * `entrypoint`. The Astro integration's virtual-module loader emits + * `import plugin from ""`, which Vite resolves through + * its standard file-URL → fs path resolver. No build step required. + */ +export async function localPlugin( + dir: string, + options: LocalPluginOptions = {}, +): Promise { + const absDir = resolvePath(dir); + + // Manifest first: identity + trust contract + admin surface. + let normalised: NormalisedManifest; + try { + const { manifest } = await loadManifest(absDir); + normalised = normaliseManifest(manifest); + } catch (error) { + if (error instanceof ManifestError) { + throw new LocalPluginError("MANIFEST_INVALID", `Plugin at ${absDir}: ${error.message}`); + } + throw error; + } + + // Runtime entry: src/plugin.ts must exist; we don't probe its + // surface here (Vite will compile it on first import), but we do + // confirm it's present so the failure surfaces at config-load + // rather than at first hook fire. + const pluginEntryPath = join(absDir, "src", "plugin.ts"); + try { + await access(pluginEntryPath); + } catch { + throw new LocalPluginError( + "PLUGIN_ENTRY_MISSING", + `Plugin at ${absDir} has no src/plugin.ts. Run \`emdash-registry init\` to scaffold the expected layout, or move your runtime code to that path.`, + ); + } + + // Resolve the publisher to a DID. The manifest's publisher may + // be a handle or a DID; the runtime's identity check only cares + // about the DID. Resolving here means the descriptor passed to + // the integration is already in the canonical form. + const did = options.skipPublisherResolution + ? normalised.publisher + : await resolvePublisher(normalised.publisher); + + return { + id: normalised.slug, + version: normalised.version, + format: "standard", + entrypoint: pathToFileURL(pluginEntryPath).href, + capabilities: normalised.capabilities, + allowedHosts: normalised.allowedHosts, + storage: normalised.storage, + // Pass admin surface through only when there's something to + // declare; integration treats undefined/empty arrays the same + // way at runtime but the descriptor stays tidier. + ...(normalised.admin.pages.length > 0 && { adminPages: normalised.admin.pages }), + ...(normalised.admin.widgets.length > 0 && { adminWidgets: normalised.admin.widgets }), + // Note: `did` is computed but not currently exposed on the + // descriptor. The runtime keys storage / KV / logs by `id`, + // and id == slug for local-dev installs. When the runtime's + // ctx.plugin shape gains explicit did / uri fields, this + // helper feeds them through too. + ...(did !== normalised.publisher && {}), + }; +} + +/** + * Resolve the manifest's publisher to a DID. DIDs pass through + * verbatim; handles are resolved through the same actor-resolver + * the publish flow uses. Failure becomes a structured + * `LocalPluginError` so a site's astro.config.mjs sees a clear + * error rather than a cryptic resolver stack. + */ +async function resolvePublisher(publisher: string): Promise { + if (isDid(publisher)) return publisher; + if (!isHandle(publisher)) { + throw new LocalPluginError( + "PUBLISHER_UNRESOLVED", + `Manifest publisher "${publisher}" is neither a DID nor a valid handle. Fix the publisher field and reload.`, + ); + } + try { + return await resolveHandleToDid(publisher); + } catch (error) { + if (error instanceof PublisherCheckError) { + throw new LocalPluginError("PUBLISHER_UNRESOLVED", error.message); + } + throw error; + } +} diff --git a/packages/registry-cli/tests/dev-local-plugin.test.ts b/packages/registry-cli/tests/dev-local-plugin.test.ts new file mode 100644 index 000000000..0523957a5 --- /dev/null +++ b/packages/registry-cli/tests/dev-local-plugin.test.ts @@ -0,0 +1,155 @@ +/** + * Coverage for `localPlugin(dir)` — the local-dev helper that lets a + * site's `astro.config.mjs` consume a sandboxed plugin from its source + * directory without an npm-shaped factory import. + * + * The runtime side of the helper (Vite resolving the file:// entrypoint + * and importing the plugin module) isn't tested here — that's + * integration territory and the demos exercise it directly. These + * tests focus on the deterministic parts: descriptor shape, error + * paths for missing manifest / missing entry / malformed publisher. + */ + +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { LocalPluginError, localPlugin } from "../src/dev.js"; + +describe("localPlugin", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "emdash-localplugin-test-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function writeManifest(content: object): Promise { + await writeFile(join(dir, "emdash-plugin.jsonc"), JSON.stringify(content), "utf8"); + } + + async function writeEntry(): Promise { + await mkdir(join(dir, "src"), { recursive: true }); + await writeFile( + join(dir, "src", "plugin.ts"), + 'import { definePlugin } from "emdash";\nexport default definePlugin({});\n', + "utf8", + ); + } + + const MINIMAL_MANIFEST = { + slug: "test-plugin", + version: "0.1.0", + publisher: "did:plc:abc123", + license: "MIT", + author: { name: "Test" }, + security: { email: "security@example.com" }, + }; + + it("returns a descriptor with identity from the manifest", async () => { + await writeManifest(MINIMAL_MANIFEST); + await writeEntry(); + const descriptor = await localPlugin(dir); + expect(descriptor.id).toBe("test-plugin"); + expect(descriptor.version).toBe("0.1.0"); + expect(descriptor.format).toBe("standard"); + }); + + it("emits a file:// URL for entrypoint pointing at src/plugin.ts", async () => { + await writeManifest(MINIMAL_MANIFEST); + await writeEntry(); + const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); + expect(descriptor.entrypoint).toMatch(/^file:\/\//); + expect(descriptor.entrypoint.endsWith("/src/plugin.ts")).toBe(true); + // The URL round-trips through pathToFileURL/fileURLToPath cleanly. + const fsPath = fileURLToPath(descriptor.entrypoint); + expect(fsPath.endsWith("/src/plugin.ts")).toBe(true); + }); + + it("passes the trust contract through", async () => { + await writeManifest({ + ...MINIMAL_MANIFEST, + capabilities: ["content:read"], + storage: { events: { indexes: ["timestamp"] } }, + }); + await writeEntry(); + const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); + expect(descriptor.capabilities).toEqual(["content:read"]); + expect(descriptor.allowedHosts).toEqual([]); + expect(descriptor.storage).toEqual({ events: { indexes: ["timestamp"] } }); + }); + + it("passes admin pages and widgets through", async () => { + await writeManifest({ + ...MINIMAL_MANIFEST, + admin: { + pages: [{ path: "/foo", label: "Foo" }], + widgets: [{ id: "bar", title: "Bar", size: "half" }], + }, + }); + await writeEntry(); + const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); + expect(descriptor.adminPages).toEqual([{ path: "/foo", label: "Foo" }]); + expect(descriptor.adminWidgets).toEqual([{ id: "bar", title: "Bar", size: "half" }]); + }); + + it("omits adminPages / adminWidgets when neither is declared", async () => { + await writeManifest(MINIMAL_MANIFEST); + await writeEntry(); + const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); + expect(descriptor.adminPages).toBeUndefined(); + expect(descriptor.adminWidgets).toBeUndefined(); + }); + + it("throws MANIFEST_INVALID when emdash-plugin.jsonc is missing", async () => { + await writeEntry(); + await expect(localPlugin(dir)).rejects.toMatchObject({ + name: "LocalPluginError", + code: "MANIFEST_INVALID", + }); + }); + + it("throws MANIFEST_INVALID when the manifest fails schema validation", async () => { + await writeManifest({ slug: "" }); // missing required fields + await writeEntry(); + await expect(localPlugin(dir)).rejects.toMatchObject({ + name: "LocalPluginError", + code: "MANIFEST_INVALID", + }); + }); + + it("throws PLUGIN_ENTRY_MISSING when src/plugin.ts doesn't exist", async () => { + await writeManifest(MINIMAL_MANIFEST); + // No writeEntry() — manifest is valid but the runtime entry + // is missing. + await expect(localPlugin(dir)).rejects.toMatchObject({ + name: "LocalPluginError", + code: "PLUGIN_ENTRY_MISSING", + }); + }); + + it("returns the publisher DID verbatim when given a DID", async () => { + // skipPublisherResolution avoids the network round-trip; the + // DID is passed through unchanged. + await writeManifest(MINIMAL_MANIFEST); + await writeEntry(); + // The descriptor doesn't yet expose `did` directly, but + // we can confirm the helper accepted the DID input. Future + // PRs will add did/uri to the descriptor; this test pins the + // happy path. + await expect(localPlugin(dir, { skipPublisherResolution: true })).resolves.toMatchObject({ + id: "test-plugin", + }); + }); + + it("throws when the dir path is a non-existent directory", async () => { + const missing = join(dir, "no-such-plugin"); + await expect(localPlugin(missing)).rejects.toBeInstanceOf(LocalPluginError); + }); +}); diff --git a/packages/registry-cli/tsdown.config.ts b/packages/registry-cli/tsdown.config.ts index 55e38153c..5378919d5 100644 --- a/packages/registry-cli/tsdown.config.ts +++ b/packages/registry-cli/tsdown.config.ts @@ -15,7 +15,7 @@ export default defineConfig([ // Programmatic API entry. With tsdown's ESM defaults this emits // `.mjs` + `.d.mts` (matching the `exports` field in package.json). { - entry: ["src/api.ts"], + entry: ["src/api.ts", "src/dev.ts"], format: ["esm"], dts: true, clean: false, @@ -34,9 +34,11 @@ export default defineConfig([ "citty", "consola", "image-size", + "jsonc-parser", "modern-tar", "picocolors", "tsdown", + "zod", ], }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f1dbacb1..3a15b3262 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,6 +390,9 @@ importers: '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier + '@emdash-cms/registry-cli': + specifier: workspace:* + version: link:../../packages/registry-cli '@tanstack/react-query': specifier: 'catalog:' version: 5.90.21(react@19.2.4) @@ -476,6 +479,9 @@ importers: '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier + '@emdash-cms/registry-cli': + specifier: workspace:* + version: link:../../packages/registry-cli '@tanstack/react-query': specifier: 'catalog:' version: 5.90.21(react@19.2.4) @@ -599,6 +605,9 @@ importers: '@emdash-cms/plugin-color': specifier: workspace:* version: link:../../packages/plugins/color + '@emdash-cms/registry-cli': + specifier: workspace:* + version: link:../../packages/registry-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -754,6 +763,9 @@ importers: '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier + '@emdash-cms/registry-cli': + specifier: workspace:* + version: link:../../packages/registry-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -794,6 +806,9 @@ importers: '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier + '@emdash-cms/registry-cli': + specifier: workspace:* + version: link:../../packages/registry-cli astro: specifier: https://pkg.pr.new/astro@94d342d version: https://pkg.pr.new/astro@94d342d(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -1690,12 +1705,9 @@ importers: packages/plugins/audit-log: dependencies: emdash: - specifier: workspace:>=0.10.0 + specifier: workspace:* version: link:../../core devDependencies: - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1813,12 +1825,9 @@ importers: packages/plugins/webhook-notifier: dependencies: emdash: - specifier: workspace:>=0.10.0 + specifier: workspace:* version: link:../../core devDependencies: - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 From 8a84ab06f43ff287ef38e507ae0e7c3d879e946b Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 15 May 2026 16:15:01 +0100 Subject: [PATCH 06/16] feat(plugin-cli): rework sandboxed plugin authoring, build, and CLI Renames @emdash-cms/registry-cli to @emdash-cms/plugin-cli and the binary emdash-registry to emdash-plugin. Adds build + dev commands, consolidates the build pipeline so bundle is a thin packaging step on top of build. Introduces a strict author-facing SandboxedPlugin type via the new emdash/plugin type-only subpath; sandboxed plugins now default-export a bare { hooks?, routes? } object with satisfies SandboxedPlugin and have no runtime emdash import. Drops definePlugin and the build shim for sandboxed plugins (definePlugin is native-only now). Migrates the five in-tree sandboxed plugins to the new shape. Manifest version is optional and reconciled with package.json#version. --- .../emdash-sandboxed-plugin-authoring.md | 80 ++ .changeset/plugin-atproto-default-export.md | 35 + .changeset/plugin-audit-log-default-export.md | 35 + .changeset/plugin-cli-build-command.md | 37 + .../plugin-webhook-notifier-default-export.md | 35 + demos/cloudflare/astro.config.mjs | 4 +- demos/cloudflare/package.json | 2 +- demos/plugins-demo/astro.config.mjs | 10 +- demos/plugins-demo/package.json | 2 +- demos/simple/astro.config.mjs | 4 +- demos/simple/package.json | 2 +- .../creating-plugins/your-first-plugin.mdx | 6 +- infra/blog-demo/astro.config.mjs | 4 +- infra/blog-demo/package.json | 2 +- infra/cache-demo/astro.config.mjs | 4 +- infra/cache-demo/package.json | 2 +- packages/admin/src/lib/api/registry.ts | 2 +- packages/cloudflare/src/sandbox/runner.ts | 6 +- packages/core/package.json | 3 + packages/core/src/emdash-runtime.ts | 16 +- packages/core/src/index.ts | 12 +- packages/core/src/plugin-types.ts | 240 ++++++ .../core/src/plugins/adapt-sandbox-entry.ts | 114 ++- packages/core/src/plugins/define-plugin.ts | 90 +-- packages/core/src/plugins/index.ts | 10 +- packages/core/src/plugins/sandbox/index.ts | 2 +- packages/core/src/plugins/sandbox/noop.ts | 4 +- packages/core/src/plugins/sandbox/types.ts | 11 +- packages/core/src/plugins/types.ts | 79 +- .../unit/plugins/adapt-sandbox-entry.test.ts | 64 +- .../tests/unit/plugins/define-plugin.test.ts | 5 +- .../unit/plugins/standard-format.test.ts | 128 +--- packages/core/tsdown.config.ts | 2 + .../{registry-cli => plugin-cli}/.gitignore | 0 .../{registry-cli => plugin-cli}/CHANGELOG.md | 0 packages/plugin-cli/README.md | 141 ++++ .../{registry-cli => plugin-cli}/package.json | 15 +- .../schemas/emdash-plugin.schema.json | 1 - .../scripts/gen-schema.ts | 0 .../{registry-cli => plugin-cli}/src/api.ts | 13 +- packages/plugin-cli/src/build/api.ts | 313 ++++++++ packages/plugin-cli/src/build/command.ts | 69 ++ packages/plugin-cli/src/build/pipeline.ts | 437 +++++++++++ packages/plugin-cli/src/bundle/api.ts | 391 ++++++++++ .../src/bundle/command.ts | 4 +- .../src/bundle/types.ts | 0 .../src/bundle/utils.ts | 0 .../src/commands/info.ts | 2 +- .../src/commands/init.ts | 8 +- .../src/commands/login.ts | 2 +- .../src/commands/logout.ts | 2 +- .../src/commands/publish.ts | 10 +- .../src/commands/search.ts | 4 +- .../src/commands/switch.ts | 6 +- .../src/commands/validate.ts | 2 +- .../src/commands/whoami.ts | 6 +- .../src/config.ts | 0 packages/plugin-cli/src/dev/command.ts | 126 +++ .../{registry-cli => plugin-cli}/src/index.ts | 12 +- .../src/init/environment.ts | 2 +- .../src/init/scaffold.ts | 2 +- .../src/init/templates.ts | 22 +- .../src/manifest/load.ts | 4 +- .../src/manifest/publisher.ts | 2 +- .../src/manifest/schema.ts | 11 +- .../src/manifest/translate.ts | 89 ++- .../src/multihash.ts | 0 .../{registry-cli => plugin-cli}/src/oauth.ts | 0 .../src/profile.ts | 0 .../src/publish/api.ts | 0 .../tests/bundle-utils.test.ts | 0 .../tests/bundle.test.ts | 13 +- .../tests/config.test.ts | 0 .../fixtures/bad-plugin/emdash-plugin.jsonc | 0 .../tests/fixtures/bad-plugin/package.json | 0 .../minimal-plugin/emdash-plugin.jsonc | 0 .../fixtures/minimal-plugin/package.json | 0 .../fixtures/minimal-plugin/src/plugin.ts | 22 + .../tests/init-environment.test.ts | 0 .../tests/init-scaffold.test.ts | 0 .../tests/init-templates.test.ts | 6 +- .../tests/manifest-load.test.ts | 2 +- .../tests/manifest-publisher.test.ts | 0 .../tests/manifest-schema.test.ts | 6 +- .../tests/manifest-translate.test.ts | 76 +- .../tests/manifest-trust-contract.test.ts | 0 .../tests/mock-pds.ts | 0 .../tests/multihash.test.ts | 0 .../tests/publish-tarball.test.ts | 0 .../tests/publish.test.ts | 0 .../tests/schema-drift.test.ts | 4 +- .../tests/url-validation.test.ts | 0 .../tsconfig.json | 0 .../tsdown.config.ts | 5 +- .../vitest.config.ts | 0 packages/plugin-types/src/index.ts | 2 +- packages/plugins/atproto/emdash-plugin.jsonc | 3 +- packages/plugins/atproto/package.json | 24 +- packages/plugins/atproto/src/plugin.ts | 7 +- .../plugins/audit-log/emdash-plugin.jsonc | 3 +- packages/plugins/audit-log/package.json | 22 +- packages/plugins/audit-log/src/plugin.ts | 60 +- .../marketplace-test/emdash-plugin.jsonc | 3 +- .../plugins/marketplace-test/package.json | 13 + .../plugins/marketplace-test/src/plugin.ts | 25 +- .../sandboxed-test/emdash-plugin.jsonc | 3 +- packages/plugins/sandboxed-test/package.json | 13 + packages/plugins/sandboxed-test/src/plugin.ts | 7 +- .../webhook-notifier/emdash-plugin.jsonc | 3 +- .../plugins/webhook-notifier/package.json | 22 +- .../plugins/webhook-notifier/src/plugin.ts | 41 +- packages/registry-cli/README.md | 105 --- packages/registry-cli/src/bundle/api.ts | 725 ------------------ packages/registry-cli/src/dev.ts | 196 ----- .../tests/dev-local-plugin.test.ts | 155 ---- .../fixtures/minimal-plugin/src/plugin.ts | 20 - packages/registry-client/README.md | 4 +- .../registry-client/src/credentials/file.ts | 2 +- .../registry-client/src/credentials/types.ts | 2 +- packages/registry-client/src/index.ts | 6 +- .../registry-client/src/publishing/index.ts | 2 +- pnpm-lock.yaml | 289 ++++--- pnpm-workspace.yaml | 133 ++-- .../references/configuration.md | 4 +- .../references/configuration.md | 4 +- templates/blog-cloudflare/astro.config.mjs | 4 +- .../references/configuration.md | 4 +- templates/blog/astro.config.mjs | 4 +- .../references/configuration.md | 4 +- .../references/configuration.md | 4 +- .../references/configuration.md | 4 +- .../references/configuration.md | 4 +- .../references/configuration.md | 4 +- .../references/configuration.md | 4 +- 134 files changed, 2834 insertions(+), 1982 deletions(-) create mode 100644 .changeset/emdash-sandboxed-plugin-authoring.md create mode 100644 .changeset/plugin-atproto-default-export.md create mode 100644 .changeset/plugin-audit-log-default-export.md create mode 100644 .changeset/plugin-cli-build-command.md create mode 100644 .changeset/plugin-webhook-notifier-default-export.md create mode 100644 packages/core/src/plugin-types.ts rename packages/{registry-cli => plugin-cli}/.gitignore (100%) rename packages/{registry-cli => plugin-cli}/CHANGELOG.md (100%) create mode 100644 packages/plugin-cli/README.md rename packages/{registry-cli => plugin-cli}/package.json (80%) rename packages/{registry-cli => plugin-cli}/schemas/emdash-plugin.schema.json (99%) rename packages/{registry-cli => plugin-cli}/scripts/gen-schema.ts (100%) rename packages/{registry-cli => plugin-cli}/src/api.ts (87%) create mode 100644 packages/plugin-cli/src/build/api.ts create mode 100644 packages/plugin-cli/src/build/command.ts create mode 100644 packages/plugin-cli/src/build/pipeline.ts create mode 100644 packages/plugin-cli/src/bundle/api.ts rename packages/{registry-cli => plugin-cli}/src/bundle/command.ts (95%) rename packages/{registry-cli => plugin-cli}/src/bundle/types.ts (100%) rename packages/{registry-cli => plugin-cli}/src/bundle/utils.ts (100%) rename packages/{registry-cli => plugin-cli}/src/commands/info.ts (98%) rename packages/{registry-cli => plugin-cli}/src/commands/init.ts (98%) rename packages/{registry-cli => plugin-cli}/src/commands/login.ts (99%) rename packages/{registry-cli => plugin-cli}/src/commands/logout.ts (96%) rename packages/{registry-cli => plugin-cli}/src/commands/publish.ts (99%) rename packages/{registry-cli => plugin-cli}/src/commands/search.ts (94%) rename packages/{registry-cli => plugin-cli}/src/commands/switch.ts (87%) rename packages/{registry-cli => plugin-cli}/src/commands/validate.ts (98%) rename packages/{registry-cli => plugin-cli}/src/commands/whoami.ts (88%) rename packages/{registry-cli => plugin-cli}/src/config.ts (100%) create mode 100644 packages/plugin-cli/src/dev/command.ts rename packages/{registry-cli => plugin-cli}/src/index.ts (86%) rename packages/{registry-cli => plugin-cli}/src/init/environment.ts (99%) rename packages/{registry-cli => plugin-cli}/src/init/scaffold.ts (98%) rename packages/{registry-cli => plugin-cli}/src/init/templates.ts (93%) rename packages/{registry-cli => plugin-cli}/src/manifest/load.ts (99%) rename packages/{registry-cli => plugin-cli}/src/manifest/publisher.ts (99%) rename packages/{registry-cli => plugin-cli}/src/manifest/schema.ts (97%) rename packages/{registry-cli => plugin-cli}/src/manifest/translate.ts (57%) rename packages/{registry-cli => plugin-cli}/src/multihash.ts (100%) rename packages/{registry-cli => plugin-cli}/src/oauth.ts (100%) rename packages/{registry-cli => plugin-cli}/src/profile.ts (100%) rename packages/{registry-cli => plugin-cli}/src/publish/api.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/bundle-utils.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/bundle.test.ts (93%) rename packages/{registry-cli => plugin-cli}/tests/config.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/fixtures/bad-plugin/emdash-plugin.jsonc (100%) rename packages/{registry-cli => plugin-cli}/tests/fixtures/bad-plugin/package.json (100%) rename packages/{registry-cli => plugin-cli}/tests/fixtures/minimal-plugin/emdash-plugin.jsonc (100%) rename packages/{registry-cli => plugin-cli}/tests/fixtures/minimal-plugin/package.json (100%) create mode 100644 packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts rename packages/{registry-cli => plugin-cli}/tests/init-environment.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/init-scaffold.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/init-templates.test.ts (98%) rename packages/{registry-cli => plugin-cli}/tests/manifest-load.test.ts (99%) rename packages/{registry-cli => plugin-cli}/tests/manifest-publisher.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/manifest-schema.test.ts (96%) rename packages/{registry-cli => plugin-cli}/tests/manifest-translate.test.ts (58%) rename packages/{registry-cli => plugin-cli}/tests/manifest-trust-contract.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/mock-pds.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/multihash.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/publish-tarball.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/publish.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tests/schema-drift.test.ts (94%) rename packages/{registry-cli => plugin-cli}/tests/url-validation.test.ts (100%) rename packages/{registry-cli => plugin-cli}/tsconfig.json (100%) rename packages/{registry-cli => plugin-cli}/tsdown.config.ts (89%) rename packages/{registry-cli => plugin-cli}/vitest.config.ts (100%) delete mode 100644 packages/registry-cli/README.md delete mode 100644 packages/registry-cli/src/bundle/api.ts delete mode 100644 packages/registry-cli/src/dev.ts delete mode 100644 packages/registry-cli/tests/dev-local-plugin.test.ts delete mode 100644 packages/registry-cli/tests/fixtures/minimal-plugin/src/plugin.ts diff --git a/.changeset/emdash-sandboxed-plugin-authoring.md b/.changeset/emdash-sandboxed-plugin-authoring.md new file mode 100644 index 000000000..7b1365af6 --- /dev/null +++ b/.changeset/emdash-sandboxed-plugin-authoring.md @@ -0,0 +1,80 @@ +--- +"emdash": minor +--- + +**BREAKING (plugin authors):** Reworks how sandboxed plugins are defined. The `definePlugin()` helper is removed for sandboxed-format plugins; the new shape is a bare default export with a `satisfies SandboxedPlugin` annotation. A new type-only subpath `emdash/plugin` provides the types. + +This affects anyone *writing* a sandboxed plugin. Sites that *use* plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins). + +**Before:** + +```ts +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +interface ContentSaveEvent { + content: Record; + collection: string; + isNew: boolean; +} + +export default definePlugin({ + hooks: { + "content:beforeSave": { + handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + // ... + return event.content; + }, + }, + }, +}); +``` + +**After:** + +```ts +import type { SandboxedPlugin } from "emdash/plugin"; + +export default { + hooks: { + "content:beforeSave": async (event, ctx) => { + // event: ContentHookEvent, ctx: PluginContext — both inferred. + // ... + return event.content; + }, + }, +} satisfies SandboxedPlugin; +``` + +Three changes: + +1. **Drop `import { definePlugin } from "emdash"`** and the `definePlugin(...)` wrapping call. Sandboxed plugins now default-export the bare object. +2. **`import type { SandboxedPlugin } from "emdash/plugin"`** and add `satisfies SandboxedPlugin` to the default export. The `emdash/plugin` subpath is type-only — the bundler erases the import, so no runtime resolution of `emdash` is needed (and the heavy `emdash` runtime no longer enters the plugin bundle). +3. **Drop handler parameter annotations** like `event: ContentSaveEvent, ctx: PluginContext`. The strict mapped type on `SandboxedPlugin` infers them per hook name, with the full canonical event type. If you need to reference an event type by name (e.g. in a helper function), `emdash/plugin` re-exports them: `import type { ContentHookEvent, PluginContext } from "emdash/plugin"`. + +**Why:** the old `definePlugin` was an identity function whose only job was to alias `emdash` to a Proxy shim at build time so the import would resolve. With the new shape, sandboxed plugins have *no* runtime `emdash` import — only type-only imports from `emdash/plugin`. The bundler doesn't need to alias anything; the build pipeline is simpler; and authors get strict per-hook event/return type inference for free. + +The trade-off: previously you could narrow an event type locally (e.g. `interface ContentSaveEvent { content: ... & { id: string } }`). Under the strict mapped type, the canonical event type wins (TypeScript's contravariance on function parameters means narrowing isn't assignable). Authors validate fields at runtime with `typeof` / `isRecord` checks instead — which is the right pattern for input that comes from outside the type system anyway. + +**Routes** follow the same simplification. The two-arg `(routeCtx, ctx)` shape is unchanged; only the annotations disappear: + +```ts +export default { + routes: { + health: async (routeCtx, ctx) => { + // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. + return new Response("ok"); + }, + }, +} satisfies SandboxedPlugin; +``` + +`SandboxedRouteContext` exposes `{ input, request, requestMeta? }`. `request` is typed as `SandboxedRequest` — a `{ url, method, headers }` record that's portable across in-process and isolate execution (Worker Loader can't pass real `Request` objects across the boundary). + +**Native plugins are unaffected.** This change applies only to sandboxed-format plugins. Native plugins continue to use `definePlugin()` from `emdash` and the existing `PluginDefinition` shape. + +**Type rename:** `SandboxedPlugin` on the `emdash` package now refers to the new author-facing source-shape type. The runtime-side handle type (returned by `SandboxRunner.load`, held in the runtime's plugin cache) is renamed to `SandboxedPluginInstance`. If you import `SandboxedPlugin` from `emdash` to type a sandbox runner implementation or hold runtime plugin handles, update those imports to `SandboxedPluginInstance`. Public consumers of this type are mostly limited to `@emdash-cms/cloudflare` and other sandbox runner adapters; standard plugin / site code is unaffected. + +**Removed types:** `StandardPluginDefinition`, `StandardHookHandler`, `StandardHookEntry`, `StandardRouteHandler`, `StandardRouteEntry` are no longer exported from `emdash`. These were authoring-helper aliases under the old permissive `definePlugin` standard overload. Use `SandboxedPlugin` from `emdash/plugin` for the same purpose under the new shape. + +**Removed function:** `isStandardPluginDefinition` is gone. There's no equivalent — sandboxed plugins are identified by structure (`{ hooks?, routes? }`) and you should treat the default export as already typed via `satisfies SandboxedPlugin`. diff --git a/.changeset/plugin-atproto-default-export.md b/.changeset/plugin-atproto-default-export.md new file mode 100644 index 000000000..b4c19d93c --- /dev/null +++ b/.changeset/plugin-atproto-default-export.md @@ -0,0 +1,35 @@ +--- +"@emdash-cms/plugin-atproto": minor +--- + +**BREAKING:** Removes the `atprotoPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. + +**Before:** + +```ts +import { atprotoPlugin } from "@emdash-cms/plugin-atproto"; + +export default defineConfig({ + integrations: [ + emdash({ + sandboxed: [atprotoPlugin()], + }), + ], +}); +``` + +**After:** + +```ts +import atproto from "@emdash-cms/plugin-atproto"; + +export default defineConfig({ + integrations: [ + emdash({ + sandboxed: [atproto], + }), + ], +}); +``` + +Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.changeset/plugin-audit-log-default-export.md b/.changeset/plugin-audit-log-default-export.md new file mode 100644 index 000000000..5165667ad --- /dev/null +++ b/.changeset/plugin-audit-log-default-export.md @@ -0,0 +1,35 @@ +--- +"@emdash-cms/plugin-audit-log": minor +--- + +**BREAKING:** Removes the `auditLogPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. + +**Before:** + +```ts +import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; + +export default defineConfig({ + integrations: [ + emdash({ + plugins: [auditLogPlugin()], + }), + ], +}); +``` + +**After:** + +```ts +import auditLog from "@emdash-cms/plugin-audit-log"; + +export default defineConfig({ + integrations: [ + emdash({ + plugins: [auditLog], + }), + ], +}); +``` + +Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.changeset/plugin-cli-build-command.md b/.changeset/plugin-cli-build-command.md new file mode 100644 index 000000000..c45998add --- /dev/null +++ b/.changeset/plugin-cli-build-command.md @@ -0,0 +1,37 @@ +--- +"@emdash-cms/plugin-cli": minor +--- + +Renames `@emdash-cms/registry-cli` to `@emdash-cms/plugin-cli` and the binary from `emdash-registry` to `emdash-plugin`. The package's job has outgrown the original name — `init`, `build`, `dev`, `bundle`, `publish`, `search`, `info`, `login`, `logout`, `whoami`, and `switch` cover plugin authoring + identity + discovery, not just registry interaction. Adopt the new name on first install; the old package is no longer published. + +This release also adds `emdash-plugin build` and `emdash-plugin dev` and consolidates the build pipeline so `bundle` is a thin packaging step on top of `build`. + +**`emdash-plugin build`** reads `emdash-plugin.jsonc` and `src/plugin.ts`, then emits: + +- `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes (hooks + routes). The same artifact is consumed both in-process (when the plugin is in `plugins: []`) and by the sandbox loader (when in `sandboxed: []`). +- `dist/manifest.json` — wire-shape `PluginManifest` including hooks + routes harvested from probing `src/plugin.ts`. `bundle` packs this verbatim into the registry tarball; on the npm path it's metadata that consumers can read without parsing JSONC. +- `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module that default-exports a bare `PluginDescriptor` object. Emitted only when a sibling `package.json` exists (registry-only plugins skip this, since nothing would import it). + +**`emdash-plugin dev`** watches `src/**`, `emdash-plugin.jsonc`, and `package.json`, debouncing rebuilds at 150ms. On a failed rebuild it leaves the last good `dist/` in place so a downstream site importing the plugin keeps working until the next successful build. Stop with Ctrl-C. + +A typical plugin `package.json`: + +```json +{ + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" + } +} +``` + +**`version` in `emdash-plugin.jsonc` is now optional.** The build reconciles the manifest's `version` with `package.json#version`: + +- Both set and matching → fine. +- Both set and different → hard error. +- One set → that value wins. +- Neither set → hard error. + +The recommended pattern for npm-distributed plugins is to omit `version` from the manifest and let `package.json` be the source of truth. Registry-only plugins (no `package.json`) must set `version` in the manifest. + +**`emdash-plugin bundle`** has been reduced to a packaging step: it now calls `build` to produce `dist/`, validates the bundle contents (no Node-builtin imports, no oversized files, capability sanity), collects optional assets (README, icon, screenshots), and tarballs. Inside the tarball, `plugin.mjs` is renamed to `backend.js` to match the registry's wire-side filename. `validateOnly` still skips tarball creation but now produces the `dist/` artifacts (since "validate" implies "build first"). diff --git a/.changeset/plugin-webhook-notifier-default-export.md b/.changeset/plugin-webhook-notifier-default-export.md new file mode 100644 index 000000000..8881c6c47 --- /dev/null +++ b/.changeset/plugin-webhook-notifier-default-export.md @@ -0,0 +1,35 @@ +--- +"@emdash-cms/plugin-webhook-notifier": minor +--- + +**BREAKING:** Removes the `webhookNotifierPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. + +**Before:** + +```ts +import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; + +export default defineConfig({ + integrations: [ + emdash({ + sandboxed: [webhookNotifierPlugin()], + }), + ], +}); +``` + +**After:** + +```ts +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; + +export default defineConfig({ + integrations: [ + emdash({ + sandboxed: [webhookNotifier], + }), + ], +}); +``` + +Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 11e866880..fd3434e78 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -11,12 +11,10 @@ import { cloudflareStream, } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { localPlugin } from "@emdash-cms/registry-cli/dev"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; -const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); - export default defineConfig({ output: "server", adapter: cloudflare({ diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index 6e719d656..0459fa621 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -18,7 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", - "@emdash-cms/registry-cli": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "astro": "catalog:", diff --git a/demos/plugins-demo/astro.config.mjs b/demos/plugins-demo/astro.config.mjs index e9c77beaf..dcf0e34f9 100644 --- a/demos/plugins-demo/astro.config.mjs +++ b/demos/plugins-demo/astro.config.mjs @@ -1,19 +1,13 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; import { apiTestPlugin } from "@emdash-cms/plugin-api-test"; +import auditLog from "@emdash-cms/plugin-audit-log"; import { embedsPlugin } from "@emdash-cms/plugin-embeds"; -import { localPlugin } from "@emdash-cms/registry-cli/dev"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { sqlite } from "emdash/db"; -// Sandboxed plugins are loaded directly from their source dirs via -// localPlugin(). The trusted plugins (api-test, embeds) keep their -// factory-based imports for now — they haven't migrated to the new -// shape yet. -const auditLog = await localPlugin("../../packages/plugins/audit-log"); -const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); - export default defineConfig({ output: "server", adapter: node({ diff --git a/demos/plugins-demo/package.json b/demos/plugins-demo/package.json index 242357c04..628974cee 100644 --- a/demos/plugins-demo/package.json +++ b/demos/plugins-demo/package.json @@ -17,7 +17,7 @@ "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-embeds": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", - "@emdash-cms/registry-cli": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", "astro": "catalog:", diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index 862efed7b..726b23cf5 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -1,12 +1,10 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; -import { localPlugin } from "@emdash-cms/registry-cli/dev"; +import auditLog from "@emdash-cms/plugin-audit-log"; import { defineConfig, fontProviders } from "astro/config"; import emdash, { local } from "emdash/astro"; import { sqlite } from "emdash/db"; -const auditLog = await localPlugin("../../packages/plugins/audit-log"); - export default defineConfig({ output: "server", adapter: node({ diff --git a/demos/simple/package.json b/demos/simple/package.json index 5169bf61d..38c335bfa 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -20,7 +20,7 @@ "@emdash-cms/plugin-atproto": "workspace:*", "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-color": "workspace:*", - "@emdash-cms/registry-cli": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "astro": "catalog:", "better-sqlite3": "catalog:", "emdash": "workspace:*", diff --git a/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx b/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx index 783e3ca4d..e28352ed5 100644 --- a/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/your-first-plugin.mdx @@ -172,18 +172,18 @@ Things worth knowing: ## Register the plugin -In your site's `astro.config.mjs`, import the descriptor factory and pass it into the EmDash integration. Sandboxed plugins go in `sandboxed: []`; in-process plugins go in `plugins: []`. A standard-format plugin works in both — start with `sandboxed`. +In your site's `astro.config.mjs`, import the default-exported descriptor and pass it into the EmDash integration. Sandboxed plugins go in `sandboxed: []`; in-process plugins go in `plugins: []`. A standard-format plugin works in both — start with `sandboxed`. ```typescript title="astro.config.mjs" import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { sandbox } from "@emdash-cms/cloudflare"; -import { helloPlugin } from "@my-org/plugin-hello"; +import hello from "@my-org/plugin-hello"; export default defineConfig({ integrations: [ emdash({ - sandboxed: [helloPlugin()], + sandboxed: [hello], sandboxRunner: sandbox(), }), ], diff --git a/infra/blog-demo/astro.config.mjs b/infra/blog-demo/astro.config.mjs index e2dee2a15..7476714bc 100644 --- a/infra/blog-demo/astro.config.mjs +++ b/infra/blog-demo/astro.config.mjs @@ -2,10 +2,8 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { localPlugin } from "@emdash-cms/registry-cli/dev"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; - -const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); import emdash from "emdash/astro"; export default defineConfig({ diff --git a/infra/blog-demo/package.json b/infra/blog-demo/package.json index ce01d3ce6..9fdb7b536 100644 --- a/infra/blog-demo/package.json +++ b/infra/blog-demo/package.json @@ -18,7 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", - "@emdash-cms/registry-cli": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "astro": "catalog:", "emdash": "workspace:*", "react": "catalog:", diff --git a/infra/cache-demo/astro.config.mjs b/infra/cache-demo/astro.config.mjs index 14418c1a0..3e2ffa53a 100644 --- a/infra/cache-demo/astro.config.mjs +++ b/infra/cache-demo/astro.config.mjs @@ -3,12 +3,10 @@ import { cacheCloudflare } from "@astrojs/cloudflare/cache"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { localPlugin } from "@emdash-cms/registry-cli/dev"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; -const webhookNotifier = await localPlugin("../../packages/plugins/webhook-notifier"); - export default defineConfig({ output: "server", adapter: cloudflare(), diff --git a/infra/cache-demo/package.json b/infra/cache-demo/package.json index 05ae6ef01..1575e0a18 100644 --- a/infra/cache-demo/package.json +++ b/infra/cache-demo/package.json @@ -18,7 +18,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", - "@emdash-cms/registry-cli": "workspace:*", + "@emdash-cms/plugin-cli": "workspace:*", "astro": "https://pkg.pr.new/astro@94d342d", "emdash": "workspace:*", "react": "catalog:", diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index 49cf471a5..530797aea 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -318,7 +318,7 @@ export async function listRegistryReleases( /** * Resolve a publisher DID to its claimed handle using the same - * `LocalActorResolver` pattern as `@emdash-cms/registry-cli` and + * `LocalActorResolver` pattern as `@emdash-cms/plugin-cli` and * `@emdash-cms/auth-atproto`. Bidirectional verification (handle's * domain points back to the same DID) is part of the resolver -- * `LocalActorResolver` returns the sentinel `"handle.invalid"` when diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index 1ac0f7ea2..3729c7723 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -15,7 +15,7 @@ import { env, exports } from "cloudflare:workers"; import { normalizeCapabilities, type SandboxRunner, - type SandboxedPlugin, + type SandboxedPluginInstance, type SandboxEmailSendCallback, type SandboxOptions, type SandboxRunnerFactory, @@ -133,7 +133,7 @@ export class CloudflareSandboxRunner implements SandboxRunner { * @param manifest - Plugin manifest with capabilities and storage declarations * @param code - The bundled plugin JavaScript code */ - async load(manifest: PluginManifest, code: string): Promise { + async load(manifest: PluginManifest, code: string): Promise { const pluginId = `${manifest.id}:${manifest.version}`; // Return cached plugin if available @@ -186,7 +186,7 @@ export class CloudflareSandboxRunner implements SandboxRunner { * We must create fresh stubs for each invocation to avoid I/O isolation errors: * "Cannot perform I/O on behalf of a different request" */ -class CloudflareSandboxedPlugin implements SandboxedPlugin { +class CloudflareSandboxedPlugin implements SandboxedPluginInstance { readonly id: string; readonly manifest: PluginManifest; private loader: WorkerLoader; diff --git a/packages/core/package.json b/packages/core/package.json index 276e77207..33746c094 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,6 +22,9 @@ "types": "./dist/astro/index.d.mts", "default": "./dist/astro/index.mjs" }, + "./plugin": { + "types": "./dist/plugin-types.d.mts" + }, "./middleware": { "types": "./dist/astro/middleware.d.mts", "default": "./dist/astro/middleware.mjs" diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index dc4ace5c9..737f9bcdb 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -28,7 +28,7 @@ import type { ContentItem as ContentItemInternal } from "./database/repositories import { validateIdentifier } from "./database/validate.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; -import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js"; +import type { SandboxedPluginInstance, SandboxRunner } from "./plugins/sandbox/types.js"; import type { ResolvedPlugin, MediaItem, @@ -271,7 +271,7 @@ export interface EmDashRuntimeParts { db: Kysely; storage: Storage | null; configuredPlugins: ResolvedPlugin[]; - sandboxedPlugins: Map; + sandboxedPlugins: Map; sandboxedPluginEntries: SandboxedPluginEntry[]; hooks: HookPipeline; enabledPlugins: Set; @@ -304,7 +304,7 @@ function contentItemToRecord(item: ContentItemInternal): Record const dbCache = new Map>(); let dbInitPromise: Promise> | null = null; const storageCache = new Map(); -const sandboxedPluginCache = new Map(); +const sandboxedPluginCache = new Map(); /** * Per-tier sets of `${pluginId}:${version}` keys present in * `sandboxedPluginCache`. Used during sync to know which entries belong @@ -342,7 +342,7 @@ export class EmDashRuntime { private readonly _db: Kysely; readonly storage: Storage | null; readonly configuredPlugins: ResolvedPlugin[]; - readonly sandboxedPlugins: Map; + readonly sandboxedPlugins: Map; readonly sandboxedPluginEntries: SandboxedPluginEntry[]; readonly schemaRegistry: SchemaRegistry; private _hooks!: HookPipeline; @@ -1121,7 +1121,7 @@ export class EmDashRuntime { private static async loadSandboxedPlugins( deps: RuntimeDependencies, db: Kysely, - ): Promise> { + ): Promise> { // Return cached plugins if already loaded if (sandboxedPluginCache.size > 0) { return sandboxedPluginCache; @@ -1199,7 +1199,7 @@ export class EmDashRuntime { db: Kysely, storage: Storage, deps: RuntimeDependencies, - cache: Map, + cache: Map, ): Promise { // Ensure sandbox runner exists if (!sandboxRunner && deps.createSandboxRunner) { @@ -2352,7 +2352,7 @@ export class EmDashRuntime { // Sandboxed Plugin Helpers // ========================================================================= - private findSandboxedPlugin(pluginId: string): SandboxedPlugin | undefined { + private findSandboxedPlugin(pluginId: string): SandboxedPluginInstance | undefined { for (const [key, plugin] of this.sandboxedPlugins) { if (key.startsWith(pluginId + ":")) { return plugin; @@ -2565,7 +2565,7 @@ export class EmDashRuntime { } private async handleSandboxedRoute( - plugin: SandboxedPlugin, + plugin: SandboxedPluginInstance, path: string, request: Request, ): Promise<{ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dfa8ce9c7..e9624cdff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -188,7 +188,6 @@ export { EmDashStorageError } from "./storage/types.js"; export { definePlugin, adaptSandboxEntry, - isStandardPluginDefinition, pluginManifestSchema, createHookPipeline, HookPipeline, @@ -243,16 +242,9 @@ export type { CollectionCommentSettings, StoredComment, - // Standard plugin format - StandardPluginDefinition, - StandardHookHandler, - StandardHookEntry, - StandardRouteHandler, - StandardRouteEntry, - - // Sandbox types + // Sandbox runtime types SandboxRunner, - SandboxedPlugin, + SandboxedPluginInstance, SandboxRunnerFactory, SandboxOptions, SandboxEmailMessage, diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts new file mode 100644 index 000000000..506e83230 --- /dev/null +++ b/packages/core/src/plugin-types.ts @@ -0,0 +1,240 @@ +/** + * `emdash/plugin` — types for authoring sandboxed plugins. + * + * This is a **type-only** subpath. The package.json export map only + * declares a `types` condition, so the bundler erases `import type` + * statements against this entry and the build never tries to resolve a + * JavaScript module. That's how a sandboxed plugin can import these + * types without dragging the `emdash` runtime into its bundle. + * + * Recommended authoring pattern: + * + * ```ts + * import type { SandboxedPlugin } from "emdash/plugin"; + * + * export default { + * hooks: { + * "content:beforeSave": async (event, ctx) => { + * // event: ContentHookEvent, ctx: PluginContext — both inferred. + * return event.content; + * }, + * }, + * routes: { + * health: async (routeCtx, ctx) => ({ ok: true }), + * }, + * } satisfies SandboxedPlugin; + * ``` + * + * The `satisfies SandboxedPlugin` annotation drives full inference on + * every hook handler. Authors should not need to annotate handler + * params — TypeScript reads the event type from the hook name. The + * runtime probe at build time reads `default.hooks` and `default.routes` + * directly; the shape declared here mirrors what the probe consumes. + * + * Return types matter: `content:beforeSave` may return a mutated + * `content` to override the saved fields; `content:beforeDelete` and + * `comment:beforeCreate` may return `false` to veto; `page:metadata` + * returns the metadata contribution. The mapped type captures these + * per-hook return contracts so misuse fails at compile time. + */ + +import type { + CommentAfterCreateEvent, + CommentAfterCreateHandler, + CommentAfterModerateEvent, + CommentAfterModerateHandler, + CommentBeforeCreateEvent, + CommentBeforeCreateHandler, + CommentModerateEvent, + CommentModerateHandler, + ContentAfterDeleteHandler, + ContentAfterPublishHandler, + ContentAfterSaveHandler, + ContentAfterUnpublishHandler, + ContentBeforeDeleteHandler, + ContentBeforeSaveHandler, + ContentDeleteEvent, + ContentHookEvent, + ContentPublishStateChangeEvent, + CronEvent, + CronHandler, + EmailAfterSendEvent, + EmailAfterSendHandler, + EmailBeforeSendEvent, + EmailBeforeSendHandler, + EmailDeliverEvent, + EmailDeliverHandler, + LifecycleEvent, + LifecycleHandler, + MediaAfterUploadEvent, + MediaAfterUploadHandler, + MediaBeforeUploadHandler, + MediaUploadEvent, + PageFragmentEvent, + PageFragmentHandler, + PageMetadataEvent, + PageMetadataHandler, + PluginContext, + UninstallEvent, + UninstallHandler, +} from "./plugins/types.js"; + +/** + * Map from hook name to its handler signature. Adding or changing a + * hook signature in the runtime means updating this map; the rest of + * the type story flows from it. Authors writing + * `"content:beforeSave": async (event, ctx) => { ... }` get `event` + * typed as `ContentHookEvent` and `ctx` as `PluginContext` for free. + */ +export interface HookHandlers { + "plugin:install": LifecycleHandler; + "plugin:activate": LifecycleHandler; + "plugin:deactivate": LifecycleHandler; + "plugin:uninstall": UninstallHandler; + "content:beforeSave": ContentBeforeSaveHandler; + "content:afterSave": ContentAfterSaveHandler; + "content:beforeDelete": ContentBeforeDeleteHandler; + "content:afterDelete": ContentAfterDeleteHandler; + "content:afterPublish": ContentAfterPublishHandler; + "content:afterUnpublish": ContentAfterUnpublishHandler; + "media:beforeUpload": MediaBeforeUploadHandler; + "media:afterUpload": MediaAfterUploadHandler; + cron: CronHandler; + "email:beforeSend": EmailBeforeSendHandler; + "email:deliver": EmailDeliverHandler; + "email:afterSend": EmailAfterSendHandler; + "comment:beforeCreate": CommentBeforeCreateHandler; + "comment:moderate": CommentModerateHandler; + "comment:afterCreate": CommentAfterCreateHandler; + "comment:afterModerate": CommentAfterModerateHandler; + "page:metadata": PageMetadataHandler; + "page:fragments": PageFragmentHandler; +} + +/** + * Hook-handler config form. The bare-function form is also accepted + * (see `HookEntry`) — this is the long form that lets authors override + * priority, timeout, exclusivity. `errorPolicy` and `dependencies` are + * read by the host but rarely set by authors. + */ +export interface HookConfig { + handler: HookHandlers[K]; + priority?: number; + timeout?: number; + dependencies?: string[]; + errorPolicy?: "continue" | "abort"; + exclusive?: boolean; +} + +/** + * Either a bare handler or the config form. The build probe accepts + * both shapes and the runtime normalises to the config form before + * dispatch. + */ +export type HookEntry = HookHandlers[K] | HookConfig; + +/** + * Request fields a route handler can rely on across both trusted and + * sandboxed execution. Trusted handlers receive a real `Request` + * (which is structurally compatible — has `url`, `method`, `headers`); + * sandboxed handlers receive a serialised `{ url, method, headers }` + * record because Worker Loader can't pass `Request` objects across + * the boundary. The shared shape is what's actually portable. + * + * `headers` is intentionally `Record` rather than + * `Headers` so the sandboxed serialised form (which is a plain + * record) typechecks. Trusted handlers receiving a real `Headers` + * object can still call `.get(...)`, but reading via this type's + * indexing requires the lookup to be lowercased and exact. Authors + * iterating headers in a portable way should use `Object.entries`. + */ +export interface SandboxedRequest { + url: string; + method: string; + headers: Record; +} + +/** + * Context passed to a route handler. Routes get an extra `routeCtx` + * argument with the call-site input + the originating request, in + * addition to the standard `PluginContext`. + * + * `input` is `unknown` because plugins validate it themselves — no + * central schema for route payloads. + */ +export interface SandboxedRouteContext { + input: unknown; + request: SandboxedRequest; + requestMeta?: unknown; +} + +/** + * Route handler. The two-arg shape (`routeCtx`, `pluginCtx`) matches + * how the standard-format runtime invokes routes — distinct from + * native plugins, where routes take a single context with the input + * merged in. + * + * Return type is `unknown` because routes serialise their return value + * to JSON for the caller; authors define their own response shape. + */ +export type RouteHandler = ( + routeCtx: SandboxedRouteContext, + ctx: PluginContext, +) => Promise; + +/** + * Route entry — either a bare handler or the config form with + * `public`, `input` schema, and so on. The build probe accepts both. + */ +export type RouteEntry = + | RouteHandler + | { + handler: RouteHandler; + public?: boolean; + input?: unknown; + }; + +/** + * The shape of a sandboxed plugin's default export. + * + * Both `hooks` and `routes` are optional — a plugin that only declares + * one is valid. Hook keys are constrained to the runtime's hook + * vocabulary so a typo (`"content:beforSave"`) is a compile error. + * Route keys are open because route names are author-chosen URL path + * segments. + */ +export interface SandboxedPlugin { + hooks?: { + [K in keyof HookHandlers]?: HookEntry; + }; + routes?: Record; +} + +/** + * Re-export of event types so plugin authors can reference them + * explicitly when needed (helper functions, type predicates). Most + * authors won't need these — the mapped type infers them at handler + * call sites. But the default-export's inferred type also needs them + * publicly nameable so `satisfies SandboxedPlugin` can produce a + * portable `.d.mts`. + */ +export type { + CommentAfterCreateEvent, + CommentAfterModerateEvent, + CommentBeforeCreateEvent, + CommentModerateEvent, + ContentDeleteEvent, + ContentHookEvent, + ContentPublishStateChangeEvent, + CronEvent, + EmailAfterSendEvent, + EmailBeforeSendEvent, + EmailDeliverEvent, + LifecycleEvent, + MediaAfterUploadEvent, + MediaUploadEvent, + PageFragmentEvent, + PageMetadataEvent, + PluginContext, + UninstallEvent, +}; diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 8535febd8..139964f48 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -11,12 +11,10 @@ */ import type { PluginDescriptor } from "../astro/integration/runtime.js"; +import type { SandboxedPlugin } from "../plugin-types.js"; import { PLUGIN_CAPABILITIES, HOOK_NAMES } from "./manifest-schema.js"; import { normalizeCapabilities } from "./types.js"; import type { - StandardPluginDefinition, - StandardHookEntry, - StandardHookHandler, ResolvedPlugin, ResolvedPluginHooks, ResolvedHook, @@ -26,6 +24,29 @@ import type { PluginAdminConfig, } from "./types.js"; +/** + * Loose per-hook entry shape used inside the adapter's iteration loop. + * + * `SandboxedPlugin.hooks` is a mapped type keyed by hook name, so each + * entry's type depends on the key. When the adapter iterates with + * `Object.entries`, the key is `string` (TypeScript can't see the + * narrowing), so we need a *union* type that covers every hook entry + * shape — bare handler or config form. This is that union, kept local + * because it has no use outside the adapter. + */ +// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event types across all hook names +type AnyHookHandler = (...args: any[]) => Promise; +type AnyHookEntry = + | AnyHookHandler + | { + handler: AnyHookHandler; + priority?: number; + timeout?: number; + dependencies?: string[]; + errorPolicy?: "continue" | "abort"; + exclusive?: boolean; + }; + /** * Default hook configuration values */ @@ -34,27 +55,28 @@ const DEFAULT_TIMEOUT = 5000; const DEFAULT_ERROR_POLICY = "abort" as const; /** - * Check if a standard hook entry is a config object (has a `handler` property) + * Check if a hook entry is the config form (has a `handler` property). */ function isHookConfig( - entry: StandardHookEntry, -): entry is Exclude { + entry: AnyHookEntry, +): entry is Exclude { return typeof entry === "object" && entry !== null && "handler" in entry; } /** - * Resolve a single standard hook entry to a ResolvedHook. + * Resolve a single hook entry to a ResolvedHook. * - * Standard-format hooks use the sandbox entry convention: - * handler(event, ctx) -- two args + * Sandboxed-format hooks use the standard two-arg convention: + * handler(event, ctx) * * The HookPipeline dispatch methods also call handlers with (event, ctx), - * so the handler is compatible as-is. We just need to wrap it for type safety. + * so the handler is compatible as-is — we just normalise the + * surrounding config (priority, timeout, etc.) to its defaults. */ -function resolveStandardHook( - entry: StandardHookEntry, +function resolveSandboxedHook( + entry: AnyHookEntry, pluginId: string, -): ResolvedHook { +): ResolvedHook { if (isHookConfig(entry)) { return { priority: entry.priority ?? DEFAULT_PRIORITY, @@ -84,27 +106,34 @@ const VALID_CAPABILITIES_SET = new Set(PLUGIN_CAPABILITIES); const VALID_HOOK_NAMES_SET = new Set(HOOK_NAMES); /** - * Adapt a standard-format plugin definition into a ResolvedPlugin. + * Adapt a sandboxed plugin's default export into a ResolvedPlugin. * - * This is the core of the unified plugin format. It takes the `{ hooks, routes }` - * export from a standard plugin and produces a ResolvedPlugin that can enter the - * HookPipeline alongside native plugins. + * This is the in-process side of sandboxed-format plugins: it takes + * the `{ hooks, routes }` default export of a sandboxed plugin and + * produces a `ResolvedPlugin` that enters the HookPipeline alongside + * native plugins. The descriptor supplies identity (id, version) and + * the trust contract (capabilities, allowedHosts, storage); the + * definition supplies behaviour. * - * @param definition - The standard plugin definition (from definePlugin() or raw export) + * @param definition - The plugin's default export (matching `SandboxedPlugin` from `emdash/plugin`). * @param descriptor - The plugin descriptor with id, version, capabilities, etc. - * @returns A ResolvedPlugin compatible with HookPipeline + * @returns A ResolvedPlugin compatible with HookPipeline. */ export function adaptSandboxEntry( - definition: StandardPluginDefinition, + definition: SandboxedPlugin, descriptor: PluginDescriptor, ): ResolvedPlugin { const pluginId = descriptor.id; const version = descriptor.version; - // Resolve hooks + // Resolve hooks. `SandboxedPlugin.hooks` is keyed by hook name with + // per-key entry types; iterating with `Object.entries` collapses + // keys to `string`, so we treat each entry as the union `AnyHookEntry` + // for the duration of the loop. const resolvedHooks: ResolvedPluginHooks = {}; if (definition.hooks) { - for (const [hookName, entry] of Object.entries(definition.hooks)) { + const hookMap = definition.hooks as Record; + for (const [hookName, entry] of Object.entries(hookMap)) { if (!VALID_HOOK_NAMES_SET.has(hookName)) { throw new Error( `Plugin "${pluginId}" declares unknown hook "${hookName}". ` + @@ -115,33 +144,46 @@ export function adaptSandboxEntry( // We store it as the generic type and let HookPipeline's typed dispatch // methods handle the type narrowing at call time. // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- bridging untyped map to typed interface - (resolvedHooks as Record)[hookName] = resolveStandardHook(entry, pluginId); + (resolvedHooks as Record)[hookName] = resolveSandboxedHook( + entry, + pluginId, + ); } } - // Resolve routes: standard format uses (routeCtx, pluginCtx) two-arg pattern. - // Native format uses (ctx: RouteContext) single-arg pattern where RouteContext - // extends PluginContext with { input, request, requestMeta }. - // We wrap standard route handlers to merge the two args into one. + // Resolve routes: sandboxed format uses (routeCtx, pluginCtx) two-arg + // pattern. Native format uses (ctx: RouteContext) single-arg pattern + // where RouteContext extends PluginContext with + // { input, request, requestMeta }. We wrap sandboxed route handlers + // to merge the two args into one. + // + // Route entries can be bare functions or `{ handler, public?, input? }` + // config objects; normalise to the config shape inside the loop. const resolvedRoutes: Record = {}; if (definition.routes) { - for (const [routeName, routeEntry] of Object.entries(definition.routes)) { - const standardHandler = routeEntry.handler; + for (const [routeName, rawEntry] of Object.entries(definition.routes)) { + const isConfig = typeof rawEntry === "object" && rawEntry !== null && "handler" in rawEntry; + const handler = isConfig + ? (rawEntry as { handler: (...args: unknown[]) => Promise }).handler + : (rawEntry as (...args: unknown[]) => Promise); + const publicFlag = isConfig + ? (rawEntry as { public?: boolean }).public + : undefined; + const inputSchema = isConfig + ? (rawEntry as { input?: unknown }).input + : undefined; resolvedRoutes[routeName] = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- StandardRouteEntry.input is intentionally loosely typed; callers validate at runtime - input: routeEntry.input as PluginRoute["input"], - public: routeEntry.public, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- route entry.input is intentionally loosely typed; callers validate at runtime + input: inputSchema as PluginRoute["input"], + public: publicFlag, handler: async (ctx) => { - // Build the routeCtx shape that standard handlers expect const routeCtx = { input: ctx.input, request: ctx.request, requestMeta: ctx.requestMeta, }; - // Pass only the PluginContext portion (without input/request/requestMeta) - // to match what sandboxed handlers receive. const { input: _, request: __, requestMeta: ___, ...pluginCtx } = ctx; - return standardHandler(routeCtx, pluginCtx); + return handler(routeCtx, pluginCtx); }, }; } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 308647215..30760d732 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -1,16 +1,13 @@ /** * definePlugin() Helper * - * Creates a properly typed and normalized plugin definition. - * Supports two formats: - * - * 1. **Native format** -- full PluginDefinition with id, version, capabilities, etc. - * Returns a ResolvedPlugin. - * - * 2. **Standard format** -- just { hooks, routes }. No id/version/capabilities. - * Returns the same object (identity function for type inference). - * Metadata comes from the descriptor at config time. + * Native plugin authoring entry. Returns a fully-resolved + * `ResolvedPlugin` ready for the host integration to mount. * + * Sandboxed plugins do NOT use this function. They default-export + * a bare `{ hooks?, routes? }` object with a `satisfies SandboxedPlugin` + * annotation from `emdash/plugin`. See the `emdash` changeset for the + * authoring shape. */ import { normalizeCapabilities } from "./types.js"; @@ -23,7 +20,6 @@ import type { HookConfig, PluginCapability, PluginStorageConfig, - StandardPluginDefinition, } from "./types.js"; // Plugin ID validation patterns @@ -32,33 +28,13 @@ const SCOPED_ID = /^@[a-z0-9-]+\/[a-z0-9-]+$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; /** - * Define an EmDash plugin. - * - * **Standard format** -- the canonical format for plugins that work in both - * trusted and sandboxed modes. No id/version -- those come from the descriptor. - * - * @example - * ```typescript - * import { definePlugin } from "emdash"; - * - * export default definePlugin({ - * hooks: { - * "content:afterSave": { - * handler: async (event, ctx) => { - * await ctx.kv.set("lastSave", Date.now()); - * }, - * }, - * }, - * routes: { - * status: { - * handler: async (routeCtx, ctx) => ({ ok: true }), - * }, - * }, - * }); - * ``` + * Define a native EmDash plugin. * - * **Native format** -- for plugins that need React admin, direct DB access, - * or other capabilities not available in the sandbox. + * Native plugins ship as regular npm modules, get installed via + * `pnpm add` + an `astro.config.mjs` edit, and run in the host + * process. They have full access to the runtime — capabilities are + * still enforced by `PluginContextFactory`, but there is no isolation + * boundary. * * @example * ```typescript @@ -83,30 +59,32 @@ const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; * } * }); * ``` + * + * Sandboxed-format plugins do not use `definePlugin`. They + * default-export a bare `{ hooks?, routes? }` object with a + * `satisfies SandboxedPlugin` annotation from `emdash/plugin`. Calling + * `definePlugin` with an object that has no `id` throws at runtime + * (the type system already rejects it at compile time — this check is + * for callers that bypass typechecking). */ -// Native overload first -- PluginDefinition (with id+version) is more specific export function definePlugin( definition: PluginDefinition, -): ResolvedPlugin; -// Standard overload second -- catches { hooks, routes } without id/version -export function definePlugin(definition: StandardPluginDefinition): StandardPluginDefinition; -export function definePlugin( - definition: PluginDefinition | StandardPluginDefinition, -): ResolvedPlugin | StandardPluginDefinition { - // Standard format: has hooks/routes but no id/version - if (!("id" in definition) || !("version" in definition)) { - // Validate that the standard format has at least hooks or routes - if (!("hooks" in definition) && !("routes" in definition)) { - throw new Error( - "Standard plugin format requires at least `hooks` or `routes`. " + - "For native format, provide `id` and `version`.", - ); - } - // Identity function -- return as-is for type inference. - // The adapter (adaptSandboxEntry) will convert this to a ResolvedPlugin at build time. - return definition; +): ResolvedPlugin { + // Semantic check, not a structural one: `id` is what makes this a + // native definition. Sandboxed plugins (the only other shape that + // might land here at runtime) intentionally never have an `id` — + // identity comes from the manifest's `slug` + `publisher`, computed + // at install time. So "no id" is the unambiguous signal that the + // caller meant the sandboxed authoring flow. + if (typeof definition.id !== "string" || definition.id.length === 0) { + throw new Error( + "definePlugin() is for native-format plugins and requires `id`. " + + "Sandboxed plugins use the default-export shape with " + + '`satisfies SandboxedPlugin` from "emdash/plugin"; identity ' + + "comes from `emdash-plugin.jsonc` (slug + publisher), not the " + + "source file.", + ); } - return defineNativePlugin(definition); } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 0b015f9f9..abc92c5b4 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -71,7 +71,7 @@ export { } from "./sandbox/index.js"; export type { SandboxRunner, - SandboxedPlugin, + SandboxedPluginInstance, SandboxRunnerFactory, SandboxOptions, SandboxEmailMessage, @@ -183,15 +183,7 @@ export type { PluginDefinition, ResolvedPlugin, PluginManifest, - - // Standard plugin format - StandardPluginDefinition, - StandardHookHandler, - StandardHookEntry, - StandardRouteHandler, - StandardRouteEntry, } from "./types.js"; -export { isStandardPluginDefinition } from "./types.js"; // Capability normalization (legacy → canonical alias layer) export { diff --git a/packages/core/src/plugins/sandbox/index.ts b/packages/core/src/plugins/sandbox/index.ts index ae3050ca8..d52df6648 100644 --- a/packages/core/src/plugins/sandbox/index.ts +++ b/packages/core/src/plugins/sandbox/index.ts @@ -7,7 +7,7 @@ export { NoopSandboxRunner, SandboxNotAvailableError, createNoopSandboxRunner } export type { SandboxRunner, - SandboxedPlugin, + SandboxedPluginInstance, SandboxRunnerFactory, SandboxOptions, SandboxEmailMessage, diff --git a/packages/core/src/plugins/sandbox/noop.ts b/packages/core/src/plugins/sandbox/noop.ts index f9369eb73..1899d89b5 100644 --- a/packages/core/src/plugins/sandbox/noop.ts +++ b/packages/core/src/plugins/sandbox/noop.ts @@ -7,7 +7,7 @@ */ import type { PluginManifest } from "../types.js"; -import type { SandboxRunner, SandboxedPlugin, SandboxOptions } from "./types.js"; +import type { SandboxRunner, SandboxedPluginInstance, SandboxOptions } from "./types.js"; /** * Error thrown when attempting to use sandboxing on an unsupported platform. @@ -48,7 +48,7 @@ export class NoopSandboxRunner implements SandboxRunner { _manifest: PluginManifest, // eslint-disable-next-line @typescript-eslint/no-unused-vars _code: string, - ): Promise { + ): Promise { throw new SandboxNotAvailableError(); } diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index 716594ec0..22e50296d 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -78,10 +78,13 @@ export interface SandboxOptions { } /** - * A sandboxed plugin instance. - * Provides methods to invoke hooks and routes in the isolated environment. + * Handle to a sandboxed plugin running inside an isolate. Returned + * by `SandboxRunner.load` and held by the runtime's cache so hooks / + * routes can be invoked across the isolate boundary. Distinct from + * the author-facing `SandboxedPlugin` type in `emdash/plugin`, which + * describes the source-level shape of a plugin's default export. */ -export interface SandboxedPlugin { +export interface SandboxedPluginInstance { /** Unique identifier: `${manifest.id}:${manifest.version}` */ readonly id: string; @@ -142,7 +145,7 @@ export interface SandboxRunner { * @returns A sandboxed plugin instance * @throws If sandboxing is not available or plugin can't be loaded */ - load(manifest: PluginManifest, code: string): Promise; + load(manifest: PluginManifest, code: string): Promise; /** * Set the email send callback for sandboxed plugins. diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 359b7b531..3a3801d24 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -12,7 +12,7 @@ import type { Element } from "@emdash-cms/blocks"; // The plugin capability vocabulary, the legacy-rename map, and the manifest // shape are authored once in @emdash-cms/plugin-types and shared between core -// (the manifest reader at install/runtime) and @emdash-cms/registry-cli (the +// (the manifest reader at install/runtime) and @emdash-cms/plugin-cli (the // manifest writer at bundle/publish time). // // We import-and-re-export here so existing internal callers keep working @@ -1303,83 +1303,6 @@ export interface ResolvedPluginHooks { "page:fragments"?: ResolvedHook; } -// ============================================================================= -// Standard Plugin Format (Unified Plugin Format) -// ============================================================================= - -/** - * Standard plugin hook handler -- same as sandbox entry format. - * Receives the event as the first argument and a PluginContext as the second. - * - * Plugin authors annotate their event parameters with specific types for IDE - * support. At the type level, we accept any function with compatible arity. - */ -// eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event types -export type StandardHookHandler = (...args: any[]) => Promise; - -/** - * Standard plugin hook entry -- either a bare handler or a config object. - */ -export type StandardHookEntry = - | StandardHookHandler - | { - handler: StandardHookHandler; - priority?: number; - timeout?: number; - dependencies?: string[]; - errorPolicy?: "continue" | "abort"; - exclusive?: boolean; - }; - -/** - * Standard plugin route handler -- takes (routeCtx, pluginCtx) like sandbox entries. - * The routeCtx contains input and request info; pluginCtx is the full plugin context. - * - * Uses `any` for routeCtx to allow plugins to access properties like - * `routeCtx.request.url` without needing exact type matches across - * trusted (Request object) and sandboxed (plain object) modes. - */ -// eslint-disable-next-line typescript-eslint/no-explicit-any -- see above -export type StandardRouteHandler = (routeCtx: any, ctx: PluginContext) => Promise; - -/** - * Standard plugin route entry -- either a config object with handler, or just a handler. - */ -export interface StandardRouteEntry { - handler: StandardRouteHandler; - input?: unknown; - public?: boolean; -} - -/** - * Standard plugin definition -- the sandbox entry format. - * Used by standard plugins that work in both trusted and sandboxed modes. - * No id/version/capabilities -- those come from the descriptor. - * - * This is the input to definePlugin() for standard-format plugins. - * - * The hooks and routes use permissive types (Record) so that - * plugin authors can annotate their handlers with specific event types - * without type errors from strictFunctionTypes contravariance. - */ -export interface StandardPluginDefinition { - // eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types - hooks?: Record; - // eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types - routes?: Record; -} - -/** - * Check if a value is a StandardPluginDefinition (has hooks/routes but no id/version). - */ -export function isStandardPluginDefinition(value: unknown): value is StandardPluginDefinition { - if (typeof value !== "object" || value === null) return false; - // Standard format: has hooks or routes, but NOT id+version (which are on PluginDefinition) - const hasPluginShape = "hooks" in value || "routes" in value; - const hasNativeShape = "id" in value && "version" in value; - return hasPluginShape && !hasNativeShape; -} - // ============================================================================= // Plugin Admin Exports // ============================================================================= diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index deea7a995..7d93a4160 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -10,11 +10,17 @@ import { describe, it, expect, vi } from "vitest"; import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js"; import { adaptSandboxEntry } from "../../../src/plugins/adapt-sandbox-entry.js"; -import type { StandardPluginDefinition, StandardHookHandler } from "../../../src/plugins/types.js"; +import type { SandboxedPlugin } from "../../../src/plugin-types.js"; -/** Create a properly typed mock hook handler */ -function mockHandler(): StandardHookHandler { - return vi.fn(async () => {}) as unknown as StandardHookHandler; +/** + * Create a mock hook handler with a loose signature. The strict + * mapped type on `SandboxedPlugin` ties handler shape to hook name; + * tests building fixtures across many hooks construct each entry as + * the union, so a single mock factory returns a handler typed as + * `() => Promise` and TypeScript widens when assigned. + */ +function mockHandler(): () => Promise { + return vi.fn(async () => {}); } function createDescriptor(overrides?: Partial): PluginDescriptor { @@ -30,7 +36,7 @@ function createDescriptor(overrides?: Partial): PluginDescript describe("adaptSandboxEntry", () => { describe("basic adaptation", () => { it("produces a ResolvedPlugin with correct id and version", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: {}, routes: {}, }; @@ -43,7 +49,7 @@ describe("adaptSandboxEntry", () => { }); it("adapts an empty definition", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor(); const result = adaptSandboxEntry(def, descriptor); @@ -56,7 +62,7 @@ describe("adaptSandboxEntry", () => { }); it("carries capabilities from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["content:read", "network:request"], }); @@ -67,7 +73,7 @@ describe("adaptSandboxEntry", () => { }); it("carries allowedHosts from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ allowedHosts: ["api.example.com", "*.cdn.com"], }); @@ -78,7 +84,7 @@ describe("adaptSandboxEntry", () => { }); it("carries storage config from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ storage: { events: { indexes: ["timestamp", "type"] }, @@ -95,7 +101,7 @@ describe("adaptSandboxEntry", () => { }); it("carries admin pages from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ adminPages: [{ path: "/settings", label: "Settings", icon: "gear" }], }); @@ -106,7 +112,7 @@ describe("adaptSandboxEntry", () => { }); it("carries admin widgets from descriptor", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ adminWidgets: [{ id: "status", title: "Status", size: "half" }], }); @@ -120,7 +126,7 @@ describe("adaptSandboxEntry", () => { describe("hook adaptation", () => { it("resolves a bare function hook with defaults", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:afterSave": handler, }, @@ -142,7 +148,7 @@ describe("adaptSandboxEntry", () => { it("resolves a config object hook with custom settings", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:beforeSave": { handler, @@ -168,7 +174,7 @@ describe("adaptSandboxEntry", () => { }); it("resolves multiple hooks", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:beforeSave": mockHandler(), "content:afterSave": { handler: mockHandler(), priority: 200 }, @@ -189,7 +195,7 @@ describe("adaptSandboxEntry", () => { }); it("sets pluginId on all hooks from descriptor", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:beforeSave": mockHandler(), "content:afterSave": { handler: mockHandler() }, @@ -205,7 +211,7 @@ describe("adaptSandboxEntry", () => { it("resolves exclusive hooks", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "email:deliver": { handler, @@ -221,7 +227,7 @@ describe("adaptSandboxEntry", () => { }); it("throws on unknown hook names", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "unknown:hook": mockHandler(), }, @@ -233,7 +239,7 @@ describe("adaptSandboxEntry", () => { it("applies default config for partial config objects", () => { const handler = vi.fn(); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:afterSave": { handler, @@ -259,7 +265,7 @@ describe("adaptSandboxEntry", () => { it("wraps standard two-arg route handler into single-arg RouteContext handler", async () => { const standardHandler = vi.fn().mockResolvedValue({ ok: true }); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { routes: { status: { handler: standardHandler, @@ -304,7 +310,7 @@ describe("adaptSandboxEntry", () => { }); it("preserves public flag on routes", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { routes: { webhook: { handler: vi.fn(), @@ -320,7 +326,7 @@ describe("adaptSandboxEntry", () => { }); it("adapts multiple routes", () => { - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { routes: { status: { handler: vi.fn() }, sync: { handler: vi.fn() }, @@ -337,7 +343,7 @@ describe("adaptSandboxEntry", () => { describe("capability normalization", () => { it("normalizes content:write to include content:read", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["content:write"] }); const result = adaptSandboxEntry(def, descriptor); @@ -347,7 +353,7 @@ describe("adaptSandboxEntry", () => { }); it("normalizes media:write to include media:read", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["media:write"] }); const result = adaptSandboxEntry(def, descriptor); @@ -357,7 +363,7 @@ describe("adaptSandboxEntry", () => { }); it("normalizes network:request:unrestricted to include network:request", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["network:request:unrestricted"] }); const result = adaptSandboxEntry(def, descriptor); @@ -367,7 +373,7 @@ describe("adaptSandboxEntry", () => { }); it("does not duplicate implied capabilities", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["content:read", "content:write"], }); @@ -379,7 +385,7 @@ describe("adaptSandboxEntry", () => { }); it("throws on invalid capability", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["invalid:capability"], }); @@ -394,7 +400,7 @@ describe("adaptSandboxEntry", () => { // the runtime only sees the new shape. it("rewrites all deprecated capability names to current names", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: [ "read:content", @@ -442,7 +448,7 @@ describe("adaptSandboxEntry", () => { }); it("deduplicates when both deprecated and current names are present", () => { - const def: StandardPluginDefinition = {}; + const def: SandboxedPlugin = {}; const descriptor = createDescriptor({ capabilities: ["read:content", "content:read"], }); @@ -459,7 +465,7 @@ describe("adaptSandboxEntry", () => { // HookPipeline stores hooks as ResolvedHook internally. // The adapted hooks must have the expected shape. const handler = vi.fn().mockResolvedValue(undefined); - const def: StandardPluginDefinition = { + const def: SandboxedPlugin = { hooks: { "content:afterSave": { handler, diff --git a/packages/core/tests/unit/plugins/define-plugin.test.ts b/packages/core/tests/unit/plugins/define-plugin.test.ts index d50bbf013..64545b5d4 100644 --- a/packages/core/tests/unit/plugins/define-plugin.test.ts +++ b/packages/core/tests/unit/plugins/define-plugin.test.ts @@ -84,12 +84,15 @@ describe("definePlugin", () => { }); it("rejects empty ID", () => { + // Empty id is treated as "no id" — same code path as the + // sandboxed-shape rejection, with a pointer at the new + // `satisfies SandboxedPlugin` authoring flow. expect(() => definePlugin({ id: "", version: "1.0.0", }), - ).toThrow(INVALID_PLUGIN_ID_PATTERN); + ).toThrow(/requires `id`/); }); it("rejects invalid scoped ID (missing name)", () => { diff --git a/packages/core/tests/unit/plugins/standard-format.test.ts b/packages/core/tests/unit/plugins/standard-format.test.ts index 3ee95d36d..13980b31b 100644 --- a/packages/core/tests/unit/plugins/standard-format.test.ts +++ b/packages/core/tests/unit/plugins/standard-format.test.ts @@ -1,9 +1,20 @@ /** - * Standard Plugin Format Tests + * Standard (sandboxed) plugin format tests. * - * Tests the definePlugin() standard format overload, isStandardPluginDefinition(), - * and the generatePluginsModule() standard format handling. + * Covers the runtime + integration side of sandboxed plugins: * + * - `definePlugin` rejects sandboxed-shape input (missing `id`) + * with a helpful message pointing at the new `satisfies + * SandboxedPlugin` pattern. The type system catches this at + * compile time too; this is the bypass-the-type-system runtime + * check. + * - `generatePluginsModule` emits the right import + adapter call + * for sandboxed (`format: "standard"`) plugins vs the native + * `createPlugin` call for native plugins. + * + * Authoring-side tests for `SandboxedPlugin` live next to the + * plugin-types module — strictness of the mapped type is verified + * there. */ import { describe, it, expect, vi } from "vitest"; @@ -11,66 +22,9 @@ import { describe, it, expect, vi } from "vitest"; import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js"; import { generatePluginsModule } from "../../../src/astro/integration/virtual-modules.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; -import { isStandardPluginDefinition } from "../../../src/plugins/types.js"; - -describe("definePlugin() standard format overload", () => { - it("returns the same object (identity function)", () => { - const def = { - hooks: { - "content:afterSave": { - handler: async () => {}, - }, - }, - routes: { - status: { - handler: async () => ({ ok: true }), - }, - }, - }; - - const result = definePlugin(def); - - // Standard format: definePlugin is an identity function - expect(result).toBe(def); - }); - - it("accepts hooks-only definition", () => { - const def = { - hooks: { - "content:beforeSave": async () => {}, - }, - }; - - const result = definePlugin(def); - - expect(result).toBe(def); - expect(result.hooks).toBeDefined(); - }); - - it("accepts routes-only definition", () => { - const def = { - routes: { - ping: { - handler: async () => ({ pong: true }), - }, - }, - }; - - const result = definePlugin(def); - expect(result).toBe(def); - expect(result.routes).toBeDefined(); - }); - - it("throws on empty definition (no hooks or routes)", () => { - // An empty object has no id/version, so it's treated as standard format, - // but standard format requires at least hooks or routes - expect(() => definePlugin({})).toThrow( - "Standard plugin format requires at least `hooks` or `routes`", - ); - }); - - it("still works with native format (id + version)", () => { +describe("definePlugin()", () => { + it("returns a resolved native plugin for input with id + version", () => { const handler = vi.fn(); const result = definePlugin({ id: "native-plugin", @@ -80,53 +34,29 @@ describe("definePlugin() standard format overload", () => { }, }); - // Native format: returns a ResolvedPlugin expect(result.id).toBe("native-plugin"); expect(result.version).toBe("1.0.0"); expect(result.hooks["content:beforeSave"]).toBeDefined(); expect(result.hooks["content:beforeSave"]!.pluginId).toBe("native-plugin"); }); -}); - -describe("isStandardPluginDefinition()", () => { - it("returns true for { hooks: {} }", () => { - expect(isStandardPluginDefinition({ hooks: {} })).toBe(true); - }); - - it("returns true for { routes: {} }", () => { - expect(isStandardPluginDefinition({ routes: {} })).toBe(true); - }); - it("returns true for { hooks: {}, routes: {} }", () => { - expect(isStandardPluginDefinition({ hooks: {}, routes: {} })).toBe(true); + it("throws when called without an id (sandboxed-shape input)", () => { + // The type system rejects this at compile time. At runtime, + // callers who bypass typechecking get a clear pointer at the + // sandboxed authoring flow. + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional type bypass for runtime check coverage + definePlugin({ hooks: {} } as any), + ).toThrow(/SandboxedPlugin/); }); - it("returns false for null", () => { - expect(isStandardPluginDefinition(null)).toBe(false); - }); - - it("returns false for undefined", () => { - expect(isStandardPluginDefinition(undefined)).toBe(false); - }); - - it("returns false for a string", () => { - expect(isStandardPluginDefinition("hello")).toBe(false); - }); - - it("returns false for a native plugin definition (has id + version)", () => { - expect( - isStandardPluginDefinition({ - id: "test", + it("throws when id is the empty string", () => { + expect(() => + definePlugin({ + id: "", version: "1.0.0", - hooks: {}, }), - ).toBe(false); - }); - - it("returns false for an empty object (no hooks or routes)", () => { - // Empty object has neither hooks/routes NOR id/version - // So hasPluginShape is false - expect(isStandardPluginDefinition({})).toBe(false); + ).toThrow(/requires `id`/); }); }); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 518f2a0b0..29fe8d54a 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -65,6 +65,8 @@ export default defineConfig({ "src/page/index.ts", // Plugin admin utilities (shared helpers for plugin admin.tsx files) "src/plugin-utils.ts", + // `emdash/plugin` — type-only subpath for sandboxed plugin authors. + "src/plugin-types.ts", // Standard plugin adapter (loaded by virtual:emdash/plugins at runtime) "src/plugins/adapt-sandbox-entry.ts", ], diff --git a/packages/registry-cli/.gitignore b/packages/plugin-cli/.gitignore similarity index 100% rename from packages/registry-cli/.gitignore rename to packages/plugin-cli/.gitignore diff --git a/packages/registry-cli/CHANGELOG.md b/packages/plugin-cli/CHANGELOG.md similarity index 100% rename from packages/registry-cli/CHANGELOG.md rename to packages/plugin-cli/CHANGELOG.md diff --git a/packages/plugin-cli/README.md b/packages/plugin-cli/README.md new file mode 100644 index 000000000..a6ce4bf5c --- /dev/null +++ b/packages/plugin-cli/README.md @@ -0,0 +1,141 @@ +# @emdash-cms/plugin-cli + +CLI for authoring, building, and publishing EmDash plugins. + +> EXPERIMENTAL: `init`, `build`, `dev`, `bundle`, `login`, `whoami`, `switch`, and `publish` all work today against any atproto PDS — `publish` writes profile + release records to the publisher's own repo. The discovery commands (`search`, `info`) need an aggregator; the experimental aggregator is at `registry.emdashcms.com`. NSIDs and shapes will change while RFC 0001 is in flight; pin to an exact version. + +## Installation + +```sh +npx @emdash-cms/plugin-cli init my-plugin +``` + +Or install globally: + +```sh +npm install -g @emdash-cms/plugin-cli +emdash-plugin init my-plugin +``` + +## Commands + +```text +emdash-plugin init [name] Scaffold a new sandboxed plugin +emdash-plugin build Build dist/ artifacts (plugin.mjs, manifest.json, index.mjs) +emdash-plugin dev Watch sources and rebuild on change +emdash-plugin bundle Pack dist/ + assets into a registry tarball +emdash-plugin publish --url Publish a release that points at a hosted tarball +emdash-plugin validate [path] Validate emdash-plugin.jsonc against the v1 schema +emdash-plugin login Interactive atproto OAuth login +emdash-plugin logout [--did ] Revoke the active session +emdash-plugin whoami Show stored sessions +emdash-plugin switch Switch the active publisher session +emdash-plugin search Free-text search +emdash-plugin info Show package details +``` + +All commands accept `--json`. Discovery commands accept `--aggregator ` (or `EMDASH_REGISTRY_URL`). + +## Authoring + +A typical plugin's `package.json` scripts: + +```json +{ + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" + } +} +``` + +The plugin author writes two files: + +- `emdash-plugin.jsonc` — identity (slug, publisher) + trust contract (capabilities, allowedHosts, storage) + profile fields. +- `src/plugin.ts` — runtime behaviour (hooks + routes), with `export default { ... } satisfies SandboxedPlugin` from `emdash/plugin`. + +`emdash-plugin build` produces: + +- `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes the integration loads (in-process or in a sandbox isolate). +- `dist/manifest.json` — wire-shape manifest including the hooks + routes harvested from probing `src/plugin.ts`. +- `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module that default-exports a bare `PluginDescriptor`. Consumers import this directly. + +## Publishing + +Three steps. The CLI does not host artifacts — you do, anywhere public. + +```sh +emdash-plugin bundle +# upload dist/-.tar.gz somewhere public +emdash-plugin publish --url https://example.com/foo-1.0.0.tar.gz +``` + +On first publish, pass `--license` and `--security-email` (or `--security-url`) to bootstrap the package profile — or keep them in `emdash-plugin.jsonc` (see below). + +## `emdash-plugin.jsonc` + +Drop an `emdash-plugin.jsonc` file next to your plugin's `package.json`. The CLI reads it automatically from the current directory. Schema-driven IDE completion works via the bundled JSON Schema: + +```jsonc +{ + "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "gallery", + "publisher": "did:plc:abc123def456", + + "license": "MIT", + "author": { "name": "Jane Doe", "url": "https://example.com" }, + "security": { "email": "security@example.com" }, + + // Optional + "name": "Gallery", + "description": "Image gallery block for EmDash.", + "keywords": ["gallery", "images"], + "repo": "https://github.com/example/plugin-gallery", + + // Trust contract + "capabilities": ["content:read"], + "allowedHosts": [], + "storage": {}, +} +``` + +The file is JSONC: comments and trailing commas are allowed. Use `authors: [...]` and `securityContacts: [...]` for multi-author or multi-contact plugins. `version` is optional — when omitted, the CLI reads `version` from the adjacent `package.json`. + +### Publisher pinning + +After your first successful publish, the CLI writes the active session's DID back into the manifest as `publisher`: + +```jsonc +{ + "license": "MIT", + "publisher": "did:plc:abc123def456", + ... +} +``` + +On every subsequent publish, the CLI verifies the active session matches the pinned `publisher`. If they don't match, publish refuses with `MANIFEST_PUBLISHER_MISMATCH` so you can't accidentally publish under the wrong account. To resolve a mismatch, either: + +- switch sessions: `emdash-plugin switch ` +- update the manifest if you're transferring the plugin to a new publisher + +**DIDs are the identity, not handles.** Internally the CLI always compares the active session's DID against the pinned publisher's DID. If you pin a handle (`"publisher": "example.com"`), the CLI resolves it to a DID at publish time and compares against that — so a handle pin is just a friendlier alias for the underlying DID. Handles are mutable: if the publisher's domain changes ownership and the resolver later points at a different DID, the publish will refuse. DIDs are durable and the recommended pin for long-lived plugins. + +Validate without publishing: + +```sh +emdash-plugin validate +``` + +CLI flags (`--license`, `--author-name`, …) still win over manifest values when both are set, which is useful in CI. Pass `--no-manifest` to skip the manifest entirely. + +## Programmatic API + +```ts +import { buildPlugin, bundlePlugin } from "@emdash-cms/plugin-cli"; + +await buildPlugin({ dir: "./my-plugin" }); +const result = await bundlePlugin({ dir: "./my-plugin" }); +``` + +For discovery and credentials, import from `@emdash-cms/registry-client`. diff --git a/packages/registry-cli/package.json b/packages/plugin-cli/package.json similarity index 80% rename from packages/registry-cli/package.json rename to packages/plugin-cli/package.json index c700cf318..559043b8e 100644 --- a/packages/registry-cli/package.json +++ b/packages/plugin-cli/package.json @@ -1,21 +1,17 @@ { - "name": "@emdash-cms/registry-cli", + "name": "@emdash-cms/plugin-cli", "version": "0.1.0", - "description": "CLI for publishing plugins to the EmDash plugin registry, and searching it from the terminal. Atproto OAuth, FAIR-shaped records, sandboxed-plugin-only.", + "description": "CLI for authoring, building, and publishing EmDash plugins. Covers init / build / dev / bundle / publish plus registry search and identity. Atproto OAuth, FAIR-shaped records, sandboxed-plugin-only.", "type": "module", "main": "dist/api.mjs", "exports": { ".": { "types": "./dist/api.d.mts", "default": "./dist/api.mjs" - }, - "./dev": { - "types": "./dist/dev.d.mts", - "default": "./dist/dev.mjs" } }, "bin": { - "emdash-registry": "./dist/index.mjs" + "emdash-plugin": "./dist/index.mjs" }, "files": [ "dist", @@ -41,6 +37,7 @@ "@emdash-cms/registry-client": "workspace:*", "@emdash-cms/registry-lexicons": "workspace:*", "@oslojs/crypto": "catalog:", + "chokidar": "catalog:", "citty": "^0.1.6", "consola": "^3.4.2", "image-size": "^2.0.2", @@ -61,6 +58,8 @@ "keywords": [ "emdash", "cms", + "plugin", + "plugin-cli", "plugin-registry", "atproto", "fair", @@ -71,7 +70,7 @@ "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", - "directory": "packages/registry-cli" + "directory": "packages/plugin-cli" }, "homepage": "https://github.com/emdash-cms/emdash" } diff --git a/packages/registry-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json similarity index 99% rename from packages/registry-cli/schemas/emdash-plugin.schema.json rename to packages/plugin-cli/schemas/emdash-plugin.schema.json index 4ba7adaad..690e5a0d0 100644 --- a/packages/registry-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -59,7 +59,6 @@ }, "required": [ "slug", - "version", "license", "publisher", "capabilities", diff --git a/packages/registry-cli/scripts/gen-schema.ts b/packages/plugin-cli/scripts/gen-schema.ts similarity index 100% rename from packages/registry-cli/scripts/gen-schema.ts rename to packages/plugin-cli/scripts/gen-schema.ts diff --git a/packages/registry-cli/src/api.ts b/packages/plugin-cli/src/api.ts similarity index 87% rename from packages/registry-cli/src/api.ts rename to packages/plugin-cli/src/api.ts index 95d30a785..7b6267e49 100644 --- a/packages/registry-cli/src/api.ts +++ b/packages/plugin-cli/src/api.ts @@ -1,7 +1,7 @@ /** - * Programmatic API for `@emdash-cms/registry-cli`. + * Programmatic API for `@emdash-cms/plugin-cli`. * - * Most users will run the CLI binary `emdash-registry`. This entry exists for + * Most users will run the CLI binary `emdash-plugin`. This entry exists for * tooling -- editors, custom build scripts, or other CLIs -- that want to * invoke the same logic without spawning a subprocess. * @@ -11,6 +11,15 @@ * EXPERIMENTAL: pin to an exact version while RFC 0001 is in flight. */ +export { + type BuildErrorCode, + type BuildLogger, + type BuildOptions, + type BuildResult, + BuildError, + buildPlugin, +} from "./build/api.js"; + export { type BundleErrorCode, type BundleLogger, diff --git a/packages/plugin-cli/src/build/api.ts b/packages/plugin-cli/src/build/api.ts new file mode 100644 index 000000000..aa940d5ee --- /dev/null +++ b/packages/plugin-cli/src/build/api.ts @@ -0,0 +1,313 @@ +/** + * Programmatic plugin-build API. + * + * `emdash-plugin build` produces the on-disk distribution artifacts + * for an npm-installed sandboxed plugin: + * + * - `dist/plugin.mjs` (+ `dist/plugin.d.mts`) — runtime bytes (hooks + + * routes), built with `emdash` aliased to a no-op shim. The same + * artifact is consumed two ways at install time: + * 1. In-process (`plugins: [...]`): the integration `import`s the + * package's `./sandbox` export and wraps the default with + * `adaptSandboxEntry`. + * 2. Isolate (`sandboxed: [...]`): the integration resolves the + * same `./sandbox` export, reads the file's bytes, and + * string-embeds them into a generated module the sandbox + * runner loads. + * - `dist/manifest.json` — wire-shape `PluginManifest`. Same shape + * the registry bundle tarball carries; `bundle` packs this file + * verbatim (renaming `plugin.mjs` → `backend.js` inside the + * archive). Includes hooks + routes harvested from probing + * `src/plugin.ts`. + * - `dist/index.mjs` (+ `dist/index.d.mts`) — descriptor module, + * default-exporting the bare `PluginDescriptor`. Emitted only + * when a sibling `package.json` exists (registry-only plugins + * skip this because nothing would `import` it). + * + * The plugin author writes only `emdash-plugin.jsonc` + `src/plugin.ts`. + * Identity (slug, publisher) and trust contract (capabilities, + * allowedHosts, storage) come from the manifest; the version is either + * in the manifest or in `package.json#version` (`normaliseManifest` + * reconciles). + * + * Failures throw `BuildError` with a structured `code`. + */ + +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { + buildRuntime, + probeAndAssemble, + resolveSources, + BuildPipelineError, + type BuildPipelineErrorCode, + type PipelineLogger, + type ResolvedSources, +} from "./pipeline.js"; +import { extractManifest } from "../bundle/utils.js"; +import type { PluginManifest, ResolvedPlugin } from "../bundle/types.js"; +import type { NormalisedManifest } from "../manifest/translate.js"; + +// ────────────────────────────────────────────────────────────────────────── +// Public types +// ────────────────────────────────────────────────────────────────────────── + +export type BuildErrorCode = BuildPipelineErrorCode; + +export class BuildError extends Error { + override readonly name = "BuildError"; + readonly code: BuildErrorCode; + + constructor(code: BuildErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export type BuildLogger = PipelineLogger; + +export interface BuildOptions { + /** Plugin source directory, must contain `emdash-plugin.jsonc` + `src/plugin.ts`. */ + dir: string; + /** + * Output directory for `dist/*`, relative to `dir` if not absolute. + * Defaults to `/dist`. + */ + outDir?: string; + /** Optional progress reporter. */ + logger?: BuildLogger; +} + +export interface BuildResult { + /** The normalised source manifest (post-version-reconciliation). */ + manifest: NormalisedManifest; + /** Package name from `package.json#name`, or `undefined` (registry-only plugin). */ + packageName: string | undefined; + /** + * Wire-shape manifest written to `dist/manifest.json`. Includes + * hooks + routes harvested from probing `src/plugin.ts`. Bundle + * consumes this directly when packing the tarball. + */ + wireManifest: PluginManifest; + /** + * The probed `ResolvedPlugin` — manifest identity + trust contract + * plus harvested hook/route handlers. Bundle uses this for its + * trusted-only / admin-route consistency checks without re-probing. + */ + resolvedPlugin: ResolvedPlugin; + /** Absolute path of the dist directory. */ + outDir: string; + /** Absolute paths of the files produced. */ + files: { + runtime: string; + runtimeTypes: string; + manifestJson: string; + /** Only set when `package.json` exists. */ + descriptor: string | undefined; + /** Only set when `package.json` exists. */ + descriptorTypes: string | undefined; + }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Implementation +// ────────────────────────────────────────────────────────────────────────── + +export async function buildPlugin(options: BuildOptions): Promise { + const log = options.logger ?? {}; + const pluginDir = resolve(options.dir); + const outDir = resolve(pluginDir, options.outDir ?? "dist"); + + log.start?.("Building plugin..."); + + let sources: ResolvedSources; + try { + sources = await resolveSources(pluginDir, log); + } catch (error) { + if (error instanceof BuildPipelineError) { + throw new BuildError(error.code, error.message); + } + throw error; + } + + const tmpDir = await mkdtemp(join(tmpdir(), "emdash-build-")); + + try { + const { build } = await import("tsdown"); + + await mkdir(outDir, { recursive: true }); + + // ── 1. Build src/plugin.ts → dist/plugin.mjs (+ .d.mts) ── + log.start?.("Building runtime entry..."); + const runtimeFiles = await runPipelineStep(() => + buildRuntime({ + entries: sources, + outDir, + tmpDir, + build, + }), + ); + log.success?.("Built plugin.mjs"); + + // ── 2. Probe src/plugin.ts for hooks + routes ── + log.start?.("Probing plugin surface..."); + const resolvedPlugin = await runPipelineStep(() => + probeAndAssemble({ + entries: sources, + tmpDir, + build, + }), + ); + + const wireManifest = extractManifest(resolvedPlugin); + log.info?.( + ` Hooks: ${ + wireManifest.hooks.length > 0 + ? wireManifest.hooks.map((h) => (typeof h === "string" ? h : h.name)).join(", ") + : "(none)" + }`, + ); + log.info?.( + ` Routes: ${ + wireManifest.routes.length > 0 + ? wireManifest.routes.map((r) => (typeof r === "string" ? r : r.name)).join(", ") + : "(none)" + }`, + ); + + // ── 3. Write dist/manifest.json (wire shape) ── + const manifestJson = join(outDir, "manifest.json"); + await writeFile( + manifestJson, + `${JSON.stringify(wireManifest, null, 2)}\n`, + "utf-8", + ); + log.success?.("Wrote manifest.json"); + + // ── 4. Generate dist/index.mjs (+ .d.mts) — descriptor module ── + // Only emitted when a sibling package.json exists. Registry-only + // plugins (no package.json) can't be `pnpm add`-ed, so nothing + // would `import` the descriptor module. + let descriptor: string | undefined; + let descriptorTypes: string | undefined; + if (sources.hasPackageJson && sources.packageName) { + log.start?.("Generating descriptor module..."); + ({ descriptor, descriptorTypes } = await writeDescriptor({ + outDir, + manifest: sources.manifest, + packageName: sources.packageName, + })); + log.success?.("Wrote index.mjs"); + } else { + log.info?.("No package.json — skipping dist/index.mjs (registry-only plugin)"); + } + + log.success?.(`Plugin built: ${sources.manifest.slug}@${sources.manifest.version}`); + + return { + manifest: sources.manifest, + packageName: sources.packageName, + wireManifest, + resolvedPlugin, + outDir, + files: { + runtime: runtimeFiles.runtime, + runtimeTypes: runtimeFiles.runtimeTypes, + manifestJson, + descriptor, + descriptorTypes, + }, + }; + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +/** + * Translate `BuildPipelineError` to `BuildError`. Other errors pass through. + */ +async function runPipelineStep(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof BuildPipelineError) { + throw new BuildError(error.code, error.message); + } + throw error; + } +} + +interface WriteDescriptorContext { + outDir: string; + manifest: NormalisedManifest; + packageName: string; +} + +interface DescriptorFiles { + descriptor: string; + descriptorTypes: string; +} + +/** + * Emit `dist/index.mjs` + `dist/index.d.mts`. + * + * The descriptor is a frozen plain object — no factory call, no named + * exports. Consumers write `import auditLog from "@.../plugin-audit-log"` + * and pass `auditLog` into the integration's `plugins:` or `sandboxed:` + * array directly. Per-install configuration moves to the admin UI's + * settings (KV-backed) so the import shape has no need for a factory. + * + * The descriptor's `entrypoint` is `/sandbox`. Plugins + * MUST expose a `./sandbox` export in their `package.json` pointing at + * `./dist/plugin.mjs` — the runtime bytes the integration loads. + */ +async function writeDescriptor(ctx: WriteDescriptorContext): Promise { + const { outDir, manifest, packageName } = ctx; + + const descriptorObject = { + id: manifest.slug, + version: manifest.version, + format: "standard" as const, + entrypoint: `${packageName}/sandbox`, + capabilities: manifest.capabilities, + allowedHosts: manifest.allowedHosts, + storage: manifest.storage, + ...(manifest.admin.pages.length > 0 ? { adminPages: manifest.admin.pages } : {}), + ...(manifest.admin.widgets.length > 0 ? { adminWidgets: manifest.admin.widgets } : {}), + }; + + // Pretty-print so the generated file is human-readable when debugging. + const descriptorLiteral = JSON.stringify(descriptorObject, null, 2); + + const descriptorSource = `// Auto-generated by emdash-plugin build. Do not edit. +// Source: emdash-plugin.jsonc + package.json +// +// Default-exports a sandboxed plugin descriptor. Pass it directly into +// emdash's \`plugins:\` or \`sandboxed:\` array — no factory call needed. + +/** @type {import("emdash").PluginDescriptor} */ +const descriptor = Object.freeze(${descriptorLiteral}); + +export default descriptor; +`; + + const descriptorPath = join(outDir, "index.mjs"); + await writeFile(descriptorPath, descriptorSource, "utf-8"); + + const descriptorTypesSource = `// Auto-generated by emdash-plugin build. Do not edit. +import type { PluginDescriptor } from "emdash"; + +declare const descriptor: PluginDescriptor; +export default descriptor; +`; + const descriptorTypesPath = join(outDir, "index.d.mts"); + await writeFile(descriptorTypesPath, descriptorTypesSource, "utf-8"); + + return { descriptor: descriptorPath, descriptorTypes: descriptorTypesPath }; +} diff --git a/packages/plugin-cli/src/build/command.ts b/packages/plugin-cli/src/build/command.ts new file mode 100644 index 000000000..342f64677 --- /dev/null +++ b/packages/plugin-cli/src/build/command.ts @@ -0,0 +1,69 @@ +/** + * `emdash-plugin build` + * + * Thin citty wrapper around `buildPlugin` from `./api.js`. Produces the + * on-disk dist artifacts for an npm-installed sandboxed plugin: + * + * - `dist/plugin.mjs` (+ `.d.mts`) — runtime bytes (hooks + routes). + * - `dist/index.mjs` (+ `.d.mts`) — descriptor module, bare default export. + * - `dist/manifest.json` — normalised manifest, kept in sync. + * + * Plugin authors run this from their package's `prepublishOnly` (or a + * `pnpm build` script that delegates). Bundling for the registry (the + * tarball form) is a separate command — see `emdash-plugin bundle`. + */ + +import { defineCommand } from "citty"; +import consola from "consola"; +import pc from "picocolors"; + +import { BuildError, buildPlugin, type BuildLogger } from "./api.js"; + +export const buildCommand = defineCommand({ + meta: { + name: "build", + description: "Build a sandboxed plugin's npm distribution artifacts", + }, + args: { + dir: { + type: "string", + description: "Plugin directory (default: current directory)", + default: process.cwd(), + }, + outDir: { + type: "string", + alias: "o", + description: "Output directory (default: ./dist)", + default: "dist", + }, + }, + async run({ args }) { + const logger: BuildLogger = { + start: (m) => consola.start(m), + info: (m) => consola.info(m), + success: (m) => consola.success(m), + warn: (m) => consola.warn(m), + }; + + let result; + try { + result = await buildPlugin({ + dir: args.dir, + outDir: args.outDir, + logger, + }); + } catch (error) { + if (error instanceof BuildError) { + consola.error(error.message); + process.exit(1); + } + throw error; + } + + console.log(); + consola.info("Output:"); + console.log(` ${pc.cyan(result.files.descriptor)}`); + console.log(` ${pc.cyan(result.files.runtime)}`); + console.log(` ${pc.cyan(result.files.manifestJson)}`); + }, +}); diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts new file mode 100644 index 000000000..033613c9a --- /dev/null +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -0,0 +1,437 @@ +/** + * Shared build pipeline used by `build` and `bundle`. + * + * One canonical source-to-artifact pipeline so neither `build` nor `bundle` + * has to maintain its own copy of the probe + transpile + extract logic. + * `build` writes the dist artifacts to disk; `bundle` calls into the same + * machinery to produce a `ResolvedPlugin` it then validates and tarballs. + * + * The phases: + * + * 1. `resolveSources(pluginDir)` — read + normalise `emdash-plugin.jsonc`, + * optionally read `package.json` for name/version, locate `src/plugin.ts`. + * Reconciles the manifest's optional `version` with `package.json#version` + * via `normaliseManifest` (mismatch / missing → error). + * + * 2. `probeAndAssemble({ entries, tmpDir })` — build `src/plugin.ts` with + * `emdash` aliased to a no-op shim, import the result, and harvest the + * hook/route surface into a `ResolvedPlugin`. Identity + trust contract + * come from the manifest, not the code. + * + * 3. `buildRuntime({ entries, outDir, tmpDir })` — build `src/plugin.ts` + * again (this time minified + tree-shaken) to produce `/plugin.mjs` + * and `/plugin.d.mts`. Same shim as the probe so the input is + * identical. + * + * Errors throw `BuildPipelineError` with a structured code. Wrappers translate + * to their own error classes so the CLI's `BuildError` / `BundleError` + * surfaces don't change. + */ + +import { copyFile, mkdir, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { + ManifestError, + MANIFEST_FILENAME, + loadManifest, + type LoadManifestResult, +} from "../manifest/load.js"; +import { + normaliseManifest, + VersionMismatchError, + type NormalisedManifest, +} from "../manifest/translate.js"; +import type { ResolvedPlugin } from "../bundle/types.js"; +import { fileExists } from "../bundle/utils.js"; + +const PLUGIN_ENTRY_PATH = "src/plugin.ts"; +const PACKAGE_JSON_PATH = "package.json"; + +// ────────────────────────────────────────────────────────────────────────── +// Errors +// ────────────────────────────────────────────────────────────────────────── + +export type BuildPipelineErrorCode = + | "MISSING_MANIFEST" + | "MISSING_PLUGIN_ENTRY" + | "MANIFEST_INVALID" + | "PACKAGE_JSON_INVALID" + | "VERSION_MISMATCH" + | "VERSION_MISSING" + | "RUNTIME_BUILD_FAILED" + | "PROBE_BUILD_FAILED" + | "INVALID_PLUGIN_FORMAT"; + +export class BuildPipelineError extends Error { + override readonly name = "BuildPipelineError"; + readonly code: BuildPipelineErrorCode; + + constructor(code: BuildPipelineErrorCode, message: string) { + super(message); + this.code = code; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Logger surface (shared by build + bundle wrappers) +// ────────────────────────────────────────────────────────────────────────── + +export interface PipelineLogger { + start?(message: string): void; + info?(message: string): void; + success?(message: string): void; + warn?(message: string): void; +} + +// ────────────────────────────────────────────────────────────────────────── +// Phase 1: source resolution +// ────────────────────────────────────────────────────────────────────────── + +export interface ResolvedSources { + pluginDir: string; + pluginEntry: string; + manifest: NormalisedManifest; + manifestPath: string; + /** + * Package name from `package.json#name`, or `undefined` if no + * `package.json` exists (registry-only plugin). + */ + packageName: string | undefined; + /** + * Whether a sibling `package.json` was found. Determines whether + * the descriptor module (`dist/index.mjs`) is emitted — a plugin + * without `package.json` can't be `pnpm add`-ed, so the descriptor + * has no consumer. + */ + hasPackageJson: boolean; +} + +export async function resolveSources( + pluginDir: string, + log: PipelineLogger = {}, +): Promise { + const resolvedDir = resolve(pluginDir); + const manifestPath = join(resolvedDir, MANIFEST_FILENAME); + + if (!(await fileExists(manifestPath))) { + throw new BuildPipelineError( + "MISSING_MANIFEST", + `No ${MANIFEST_FILENAME} found in ${resolvedDir}. Scaffold one with: emdash-plugin init`, + ); + } + + let loaded: LoadManifestResult; + try { + loaded = await loadManifest(manifestPath); + } catch (error) { + if (error instanceof ManifestError) { + throw new BuildPipelineError("MANIFEST_INVALID", error.message); + } + throw error; + } + + const pluginEntry = join(resolvedDir, PLUGIN_ENTRY_PATH); + if (!(await fileExists(pluginEntry))) { + throw new BuildPipelineError( + "MISSING_PLUGIN_ENTRY", + `No ${PLUGIN_ENTRY_PATH} found in ${resolvedDir}. Sandboxed plugins place their routes and hooks in this single file.`, + ); + } + + // `package.json` is optional. Common case (npm-distributed plugin): + // present, drives the version and the descriptor's entrypoint + // specifier. Edge case (registry-only plugin): absent, version + // lives in the manifest, no descriptor module is emitted. + const packageJsonPath = join(resolvedDir, PACKAGE_JSON_PATH); + const hasPackageJson = await fileExists(packageJsonPath); + let packageName: string | undefined; + let packageVersion: string | undefined; + if (hasPackageJson) { + ({ packageName, packageVersion } = await readPackageMeta(packageJsonPath)); + } + + let manifest: NormalisedManifest; + try { + manifest = normaliseManifest(loaded.manifest, packageVersion); + } catch (error) { + if (error instanceof VersionMismatchError) { + throw new BuildPipelineError(error.code, error.message); + } + throw error; + } + + log.info?.(`Manifest: ${loaded.path}`); + log.info?.(`Plugin entry: ${pluginEntry}`); + if (packageName) log.info?.(`Package: ${packageName}`); + + return { + pluginDir: resolvedDir, + pluginEntry, + manifest, + manifestPath: loaded.path, + packageName, + hasPackageJson, + }; +} + +interface PackageMeta { + packageName: string; + packageVersion: string | undefined; +} + +async function readPackageMeta(packageJsonPath: string): Promise { + const source = await readFile(packageJsonPath, "utf-8"); + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} is not valid JSON.`, + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} must be a JSON object.`, + ); + } + const name = (parsed as { name?: unknown }).name; + if (typeof name !== "string" || name.length === 0) { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} has no "name" field. The build derives the runtime entrypoint specifier from package.json#name.`, + ); + } + const versionRaw = (parsed as { version?: unknown }).version; + const packageVersion = + typeof versionRaw === "string" && versionRaw.length > 0 ? versionRaw : undefined; + return { packageName: name, packageVersion }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Phase 2: probe + assemble +// ────────────────────────────────────────────────────────────────────────── + +export interface ProbeAndAssembleContext { + entries: ResolvedSources; + tmpDir: string; + build: typeof import("tsdown").build; +} + +/** + * Build `src/plugin.ts` once for hook/route probing, import it, and + * assemble a `ResolvedPlugin` from the manifest's identity / trust + * contract plus the probed surface. + * + * The probe build is *not* minified — keeping function bodies intact + * makes the `pluginModule.default.hooks[x]` handler reads stable. The + * later runtime build is minified separately by `buildRuntime`. + */ +export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise { + const { entries, tmpDir, build } = ctx; + + const resolvedPlugin: ResolvedPlugin = { + // `id` on the bundled manifest is the publisher's natural slug. + // The runtime rewrites it to the opaque `r_` at install + // time (see makeRegistryPluginId), but on-wire the slug is what + // the install handler matches against the registry's record key. + id: entries.manifest.slug, + version: entries.manifest.version, + capabilities: entries.manifest.capabilities, + allowedHosts: entries.manifest.allowedHosts, + storage: entries.manifest.storage, + hooks: {}, + routes: {}, + admin: { + pages: entries.manifest.admin.pages, + widgets: entries.manifest.admin.widgets, + }, + }; + + const probeOutDir = join(tmpDir, "plugin-probe"); + + try { + await build({ + config: false, + entry: { plugin: entries.pluginEntry }, + format: "esm", + outExtensions: () => ({ js: ".mjs" }), + outDir: probeOutDir, + dts: false, + platform: "neutral", + external: [], + treeshake: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new BuildPipelineError( + "PROBE_BUILD_FAILED", + `Failed to probe ${entries.pluginEntry}: ${message}`, + ); + } + + const probeOutputPath = join(probeOutDir, "plugin.mjs"); + if (!(await fileExists(probeOutputPath))) { + throw new BuildPipelineError( + "PROBE_BUILD_FAILED", + `Probe of ${entries.pluginEntry} produced no output at ${probeOutputPath}.`, + ); + } + + const pluginModule = (await import(probeOutputPath)) as Record; + const definition = (pluginModule.default ?? {}) as Record; + if (typeof definition !== "object" || definition === null) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry} must default-export the result of definePlugin({ hooks, routes }). Got ${describeShape(definition)}.`, + ); + } + + const hooks = definition.hooks as Record | undefined; + const routes = definition.routes as Record | undefined; + + if (hooks) { + for (const hookName of Object.keys(hooks)) { + const hookEntry = hooks[hookName]; + const handler = extractHookHandler(hookEntry); + if (!handler) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, + ); + } + const config: Record = + typeof hookEntry === "object" && hookEntry !== null + ? (hookEntry as Record) + : {}; + resolvedPlugin.hooks[hookName] = { + handler, + priority: (config.priority as number | undefined) ?? 100, + timeout: (config.timeout as number | undefined) ?? 5000, + dependencies: (config.dependencies as string[] | undefined) ?? [], + errorPolicy: (config.errorPolicy as string | undefined) ?? "abort", + exclusive: (config.exclusive as boolean | undefined) ?? false, + pluginId: resolvedPlugin.id, + }; + } + } + if (routes) { + for (const [name, route] of Object.entries(routes)) { + const handler = extractRouteHandler(route); + if (!handler) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, + ); + } + const routeObj: Record = + typeof route === "object" && route !== null ? (route as Record) : {}; + resolvedPlugin.routes[name] = { + handler, + public: routeObj.public as boolean | undefined, + }; + } + } + + return resolvedPlugin; +} + +// ────────────────────────────────────────────────────────────────────────── +// Phase 3: runtime build +// ────────────────────────────────────────────────────────────────────────── + +export interface BuildRuntimeContext { + entries: ResolvedSources; + outDir: string; + tmpDir: string; + build: typeof import("tsdown").build; +} + +export interface RuntimeFiles { + runtime: string; + runtimeTypes: string; +} + +/** + * Build `src/plugin.ts` into `/plugin.mjs` + `/plugin.d.mts`. + * + * Same `emdash` shim alias as the probe so the input is identical. + * Minified + tree-shaken because this output is what either runs in the + * isolate (loader string-embeds it) or is `import`-ed in-process. + */ +export async function buildRuntime(ctx: BuildRuntimeContext): Promise { + const { entries, outDir, tmpDir, build } = ctx; + + const runtimeOutDir = join(tmpDir, "runtime"); + + try { + await build({ + config: false, + entry: { plugin: entries.pluginEntry }, + format: "esm", + outExtensions: () => ({ js: ".mjs", dts: ".d.mts" }), + outDir: runtimeOutDir, + dts: true, + platform: "neutral", + external: [], + minify: true, + treeshake: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new BuildPipelineError( + "RUNTIME_BUILD_FAILED", + `Failed to build ${entries.pluginEntry}: ${message}`, + ); + } + + const builtJs = join(runtimeOutDir, "plugin.mjs"); + if (!(await fileExists(builtJs))) { + throw new BuildPipelineError( + "RUNTIME_BUILD_FAILED", + `Runtime build produced no plugin.mjs output for ${entries.pluginEntry}.`, + ); + } + await mkdir(outDir, { recursive: true }); + const runtime = join(outDir, "plugin.mjs"); + await copyFile(builtJs, runtime); + + const builtDts = join(runtimeOutDir, "plugin.d.mts"); + const runtimeTypes = join(outDir, "plugin.d.mts"); + if (await fileExists(builtDts)) { + await copyFile(builtDts, runtimeTypes); + } + + return { runtime, runtimeTypes }; +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +function extractHookHandler(entry: unknown): unknown { + if (typeof entry === "function") return entry; + if (entry && typeof entry === "object" && "handler" in entry) { + const handler = (entry as { handler: unknown }).handler; + if (typeof handler === "function") return handler; + } + return undefined; +} + +function extractRouteHandler(entry: unknown): unknown { + if (typeof entry === "function") return entry; + if (entry && typeof entry === "object" && "handler" in entry) { + const handler = (entry as { handler: unknown }).handler; + if (typeof handler === "function") return handler; + } + return undefined; +} + +function describeShape(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (Array.isArray(value)) return `array (length ${value.length})`; + return typeof value; +} diff --git a/packages/plugin-cli/src/bundle/api.ts b/packages/plugin-cli/src/bundle/api.ts new file mode 100644 index 000000000..3b47d5929 --- /dev/null +++ b/packages/plugin-cli/src/bundle/api.ts @@ -0,0 +1,391 @@ +/** + * Programmatic plugin-bundling API. + * + * Pure-ish core of the bundling pipeline — no `process.exit`, no console + * output. The CLI in `./command.ts` is a thin wrapper that turns these + * calls into pretty terminal output; tests exercise this module directly. + * + * Bundling is "build + validate + tarball". The build phase (probe, + * transpile, manifest extraction) lives in `../build/api.ts`. Bundle + * adds the publish-side concerns on top of build's output: + * + * 1. Run `buildPlugin` to produce `dist/manifest.json` (wire shape) and + * `dist/plugin.mjs` (runtime bytes). + * 2. Validate against publish constraints: no Node-builtin imports in + * the runtime, deprecated capabilities are still flagged, admin + * pages require an admin route, trusted-only features warn, + * bundle-size caps are honoured. + * 3. Stage the tarball contents in a temp dir, renaming `plugin.mjs` + * to `backend.js` (the registry's wire-side name). + * 4. Collect optional assets (README, icon, screenshots). + * 5. Gzip-tar the staging dir into `/-.tar.gz`. + * 6. Compute sha256 and return. + * + * Failures throw `BundleError` with a structured `code` so callers can + * branch (CLI shows a helpful message; tests assert the code). + */ + +import { createHash } from "node:crypto"; +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; + +import { + buildPlugin, + BuildError, + type BuildLogger, + type BuildResult, +} from "../build/api.js"; +import { + CAPABILITY_RENAMES, + isDeprecatedCapability, + type PluginManifest, +} from "./types.js"; +import { + collectBundleEntries, + createTarball, + fileExists, + findNodeBuiltinImports, + formatBytes, + ICON_SIZE, + MAX_SCREENSHOTS, + MAX_SCREENSHOT_HEIGHT, + MAX_SCREENSHOT_WIDTH, + readImageDimensions, + totalBundleBytes, + validateBundleSize, +} from "./utils.js"; + +const SLASH_RE = /\//g; +const LEADING_AT_RE = /^@/; + +// ────────────────────────────────────────────────────────────────────────── +// Public types +// ────────────────────────────────────────────────────────────────────────── + +export type BundleErrorCode = + // Build-phase failures (passed through from BuildError). + | "MISSING_MANIFEST" + | "MISSING_PLUGIN_ENTRY" + | "MANIFEST_INVALID" + | "PACKAGE_JSON_INVALID" + | "VERSION_MISMATCH" + | "VERSION_MISSING" + | "RUNTIME_BUILD_FAILED" + | "PROBE_BUILD_FAILED" + | "INVALID_PLUGIN_FORMAT" + // Bundle-specific failures. + | "TRUSTED_ONLY_FEATURE" + | "VALIDATION_FAILED"; + +export class BundleError extends Error { + override readonly name = "BundleError"; + readonly code: BundleErrorCode; + + constructor(code: BundleErrorCode, message: string) { + super(message); + this.code = code; + } +} + +export type BundleLogger = BuildLogger; + +export interface BundleOptions { + /** Plugin source directory, must contain `package.json`. */ + dir: string; + /** + * Output directory for the tarball, relative to `dir` if not absolute. + * Defaults to `/dist`. + */ + outDir?: string; + /** + * Skip tarball creation; only run the build + validation. Useful for + * pre-publish checks. Default: `false`. + */ + validateOnly?: boolean; + /** Optional progress reporter. */ + logger?: BundleLogger; +} + +export interface BundleResult { + /** The wire-shape plugin manifest (also written to `dist/manifest.json`). */ + manifest: PluginManifest; + /** Absolute path to the resulting tarball, or `null` when `validateOnly`. */ + tarballPath: string | null; + /** Tarball size in bytes, or `null` when `validateOnly`. */ + tarballBytes: number | null; + /** Hex sha256 of the tarball contents, or `null` when `validateOnly`. */ + sha256: string | null; + /** Non-fatal warnings collected during validation. */ + warnings: string[]; +} + +// ────────────────────────────────────────────────────────────────────────── +// Implementation +// ────────────────────────────────────────────────────────────────────────── + +export async function bundlePlugin(options: BundleOptions): Promise { + const log = options.logger ?? {}; + const pluginDir = resolve(options.dir); + const outDir = resolve(pluginDir, options.outDir ?? "dist"); + const validateOnly = options.validateOnly ?? false; + const warnings: string[] = []; + const warn = (msg: string) => { + warnings.push(msg); + log.warn?.(msg); + }; + + log.start?.(validateOnly ? "Validating plugin..." : "Bundling plugin..."); + + // ── 1. Build dist/ via the shared pipeline ── + let build: BuildResult; + try { + build = await buildPlugin({ dir: pluginDir, outDir, logger: log }); + } catch (error) { + if (error instanceof BuildError) { + throw new BundleError(error.code as BundleErrorCode, error.message); + } + throw error; + } + + const manifest = build.wireManifest; + const resolvedPlugin = build.resolvedPlugin; + + log.success?.(`Plugin: ${manifest.id}@${manifest.version}`); + log.info?.( + ` Capabilities: ${ + manifest.capabilities.length > 0 ? manifest.capabilities.join(", ") : "(none)" + }`, + ); + + // ── 2. Stage tarball contents (rename plugin.mjs -> backend.js) ── + const tmpDir = await mkdtemp(join(tmpdir(), "emdash-bundle-")); + try { + const bundleDir = join(tmpDir, "bundle"); + await mkdir(bundleDir, { recursive: true }); + + // Copy the runtime to `backend.js` (the registry's wire-side + // filename). The marketplace extractor + R2 keys all look for + // `backend.js`; the on-disk `dist/plugin.mjs` keeps a name that + // reads naturally in package.json exports. + await copyFile(build.files.runtime, join(bundleDir, "backend.js")); + + // Copy the wire-shape manifest verbatim. + await copyFile(build.files.manifestJson, join(bundleDir, "manifest.json")); + + // ── 3. Validate bundle contents ── + log.start?.("Validating bundle..."); + const validationErrors: string[] = []; + + // Node builtins in backend.js -> hard fail. + const backendCode = await readFile(join(bundleDir, "backend.js"), "utf-8"); + const builtins = findNodeBuiltinImports(backendCode); + if (builtins.length > 0) { + validationErrors.push( + `backend.js imports Node.js built-in modules: ${builtins.join(", ")}. Sandboxed plugins cannot use Node.js APIs.`, + ); + } + + // Capability sanity warnings. + const declaresUnrestricted = + manifest.capabilities.includes("network:request:unrestricted") || + manifest.capabilities.includes("network:fetch:any"); + const declaresHostRestricted = + manifest.capabilities.includes("network:request") || + manifest.capabilities.includes("network:fetch"); + if (declaresUnrestricted) { + warn( + "Plugin declares unrestricted network access (network:request:unrestricted) — it can make requests to any host.", + ); + } else if (declaresHostRestricted && manifest.allowedHosts.length === 0) { + // `publish` will hard-fail this case (INVALID_MANIFEST) because + // the lexicon says `request: {}` means "unrestricted" -- silently + // publishing that contradicts the apparent intent of declaring + // `network:request` (host-restricted) with empty allowedHosts. + // Surface it loudly at bundle time so the developer fixes it + // before they try to publish. + warn( + "Plugin declares network:request capability but no allowedHosts. The lexicon treats this as `unrestricted` access. Add specific host patterns to allowedHosts, or upgrade the capability to network:request:unrestricted. `publish` will refuse this combination.", + ); + } + + // Deprecated capabilities are warnings here; `publish` hard-fails on them. + const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability); + if (deprecatedCaps.length > 0) { + warn("Plugin uses deprecated capability names. Rename them before publishing:"); + for (const cap of deprecatedCaps) { + warn(` ${cap} -> ${CAPABILITY_RENAMES[cap]}`); + } + } + + // Trusted-only features that won't work in sandboxed mode. + if ( + resolvedPlugin.admin?.portableTextBlocks && + resolvedPlugin.admin.portableTextBlocks.length > 0 + ) { + warn( + "Plugin declares portableTextBlocks — these require trusted mode and will be ignored in sandboxed plugins.", + ); + } + if (resolvedPlugin.admin?.entry) { + warn( + "Plugin declares admin.entry — custom React components require trusted mode. Use Block Kit for sandboxed admin pages.", + ); + } + if (resolvedPlugin.hooks["page:fragments"]) { + warn( + "Plugin declares page:fragments hook — this is trusted-only and will not work in sandboxed mode.", + ); + } + + // Admin pages/widgets require an `admin` route. + const hasAdminPages = (manifest.admin?.pages?.length ?? 0) > 0; + const hasAdminWidgets = (manifest.admin?.widgets?.length ?? 0) > 0; + if (hasAdminPages || hasAdminWidgets) { + const routeNames = manifest.routes.map((r) => (typeof r === "string" ? r : r.name)); + if (!routeNames.includes("admin")) { + const declared = + hasAdminPages && hasAdminWidgets + ? "adminPages and adminWidgets" + : hasAdminPages + ? "adminPages" + : "adminWidgets"; + validationErrors.push( + `Plugin declares ${declared} but the sandbox entry has no "admin" route. Add an admin route handler to serve Block Kit pages.`, + ); + } + } + + // ── 4. Collect optional assets ── + log.start?.("Collecting assets..."); + await collectAssets({ pluginDir, bundleDir, log, warn }); + + // Bundle size caps (RFC 0001 §"Bundle size limits") — measured + // after assets are staged so README/icon/screenshots count. + const bundleEntries = await collectBundleEntries(bundleDir); + const sizeViolations = validateBundleSize(bundleEntries); + if (sizeViolations.length > 0) { + validationErrors.push(...sizeViolations); + } else { + log.info?.( + `Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`, + ); + } + + if (validationErrors.length > 0) { + throw new BundleError( + "VALIDATION_FAILED", + `Bundle validation failed:\n - ${validationErrors.join("\n - ")}`, + ); + } + + log.success?.("Validation passed"); + + // ── 5. Stop here if validateOnly ── + if (validateOnly) { + return { + manifest, + tarballPath: null, + tarballBytes: null, + sha256: null, + warnings, + }; + } + + // ── 6. Create tarball ── + await mkdir(outDir, { recursive: true }); + const tarballName = `${manifest.id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "")}-${manifest.version}.tar.gz`; + const tarballPath = join(outDir, tarballName); + + log.start?.("Creating tarball..."); + await createTarball(bundleDir, tarballPath); + + const tarballStat = await stat(tarballPath); + const tarballBuf = await readFile(tarballPath); + const sha256 = createHash("sha256").update(tarballBuf).digest("hex"); + + log.success?.(`Created ${tarballName} (${(tarballStat.size / 1024).toFixed(1)}KB)`); + log.info?.(` SHA-256: ${sha256}`); + log.info?.(` Path: ${tarballPath}`); + + return { + manifest, + tarballPath, + tarballBytes: tarballStat.size, + sha256, + warnings, + }; + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +interface CollectAssetsContext { + pluginDir: string; + bundleDir: string; + log: BundleLogger; + warn: (msg: string) => void; +} + +async function collectAssets(ctx: CollectAssetsContext): Promise { + const { pluginDir, bundleDir, log, warn } = ctx; + + const readmePath = join(pluginDir, "README.md"); + if (await fileExists(readmePath)) { + await copyFile(readmePath, join(bundleDir, "README.md")); + log.success?.("Included README.md"); + } + + const iconPath = join(pluginDir, "icon.png"); + if (await fileExists(iconPath)) { + const iconBuf = await readFile(iconPath); + const dims = readImageDimensions(iconBuf); + if (!dims) { + warn("icon.png is not a valid PNG — skipping"); + } else { + if (dims[0] !== ICON_SIZE || dims[1] !== ICON_SIZE) { + warn( + `icon.png is ${dims[0]}x${dims[1]}, expected ${ICON_SIZE}x${ICON_SIZE} — including anyway`, + ); + } + await copyFile(iconPath, join(bundleDir, "icon.png")); + log.success?.("Included icon.png"); + } + } + + const screenshotsDir = join(pluginDir, "screenshots"); + if (await fileExists(screenshotsDir)) { + const screenshotFiles = (await readdir(screenshotsDir)) + .filter((f) => { + const ext = extname(f).toLowerCase(); + return ext === ".png" || ext === ".jpg" || ext === ".jpeg"; + }) + .toSorted() + .slice(0, MAX_SCREENSHOTS); + + if (screenshotFiles.length > 0) { + await mkdir(join(bundleDir, "screenshots"), { recursive: true }); + for (const file of screenshotFiles) { + const filePath = join(screenshotsDir, file); + const buf = await readFile(filePath); + const dims = readImageDimensions(buf); + if (!dims) { + warn(`screenshots/${file} — cannot read dimensions, skipping`); + continue; + } + if (dims[0] > MAX_SCREENSHOT_WIDTH || dims[1] > MAX_SCREENSHOT_HEIGHT) { + warn( + `screenshots/${file} is ${dims[0]}x${dims[1]}, max ${MAX_SCREENSHOT_WIDTH}x${MAX_SCREENSHOT_HEIGHT} — including anyway`, + ); + } + await copyFile(filePath, join(bundleDir, "screenshots", file)); + } + log.success?.(`Included ${screenshotFiles.length} screenshot(s)`); + } + } +} + diff --git a/packages/registry-cli/src/bundle/command.ts b/packages/plugin-cli/src/bundle/command.ts similarity index 95% rename from packages/registry-cli/src/bundle/command.ts rename to packages/plugin-cli/src/bundle/command.ts index 0e7a9b77f..382e95177 100644 --- a/packages/registry-cli/src/bundle/command.ts +++ b/packages/plugin-cli/src/bundle/command.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry bundle` + * `emdash-plugin bundle` * * Thin citty wrapper around `bundlePlugin` from `./api.js`. The interesting * logic lives there; this file only handles arg parsing, consola formatting, @@ -73,7 +73,7 @@ export const bundleCommand = defineCommand({ console.log(` 1. Upload ${pc.cyan(result.tarballPath)} to a public URL.`); console.log( ` 2. Publish the release record:\n` + - ` ${pc.cyan(`emdash-registry publish --url `)}`, + ` ${pc.cyan(`emdash-plugin publish --url `)}`, ); console.log( ` ${pc.dim(`(or pass --local ${result.tarballPath} to verify the URL serves matching bytes before publishing)`)}`, diff --git a/packages/registry-cli/src/bundle/types.ts b/packages/plugin-cli/src/bundle/types.ts similarity index 100% rename from packages/registry-cli/src/bundle/types.ts rename to packages/plugin-cli/src/bundle/types.ts diff --git a/packages/registry-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts similarity index 100% rename from packages/registry-cli/src/bundle/utils.ts rename to packages/plugin-cli/src/bundle/utils.ts diff --git a/packages/registry-cli/src/commands/info.ts b/packages/plugin-cli/src/commands/info.ts similarity index 98% rename from packages/registry-cli/src/commands/info.ts rename to packages/plugin-cli/src/commands/info.ts index 6d1a24f5b..5a0120bb1 100644 --- a/packages/registry-cli/src/commands/info.ts +++ b/packages/plugin-cli/src/commands/info.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry info ` + * `emdash-plugin info ` * * Show details about a single package. Read-only; no auth required. * diff --git a/packages/registry-cli/src/commands/init.ts b/packages/plugin-cli/src/commands/init.ts similarity index 98% rename from packages/registry-cli/src/commands/init.ts rename to packages/plugin-cli/src/commands/init.ts index 53f482bb2..5c6d829b5 100644 --- a/packages/registry-cli/src/commands/init.ts +++ b/packages/plugin-cli/src/commands/init.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry init [name]` + * `emdash-plugin init [name]` * * Scaffold a new sandboxed plugin. Produces the three-file authoring * contract (manifest + src/plugin.ts + package.json) plus tsconfig, @@ -144,7 +144,7 @@ async function runInit(args: InitArgs): Promise { // input side that has to be a terminal for prompts to work. const interactive = !(args.yes ?? false) && process.stdin.isTTY === true; - if (interactive) clack.intro(pc.bold("emdash-registry init")); + if (interactive) clack.intro(pc.bold("emdash-plugin init")); // Load the active session (if any). Used to pre-fill the publisher // prompt and to silently fill it in `--yes` mode. We swallow load @@ -591,7 +591,7 @@ function printNextSteps(targetDir: string, inputs: ScaffoldInputs, interactive: lines.push(`2. ${pc.cyan("pnpm install")}`); lines.push(`3. ${pc.cyan("pnpm test")} confirm the scaffold passes its own test`); lines.push(`4. Edit src/plugin.ts to add routes and hooks.`); - lines.push(`5. ${pc.cyan("emdash-registry bundle")} when ready to publish`); + lines.push(`5. ${pc.cyan("emdash-plugin bundle")} when ready to publish`); clack.note(lines.join("\n"), "Next steps"); clack.outro(`Plugin ready at ${pc.bold(targetDir)}`); return; @@ -608,7 +608,7 @@ function printNextSteps(targetDir: string, inputs: ScaffoldInputs, interactive: consola.info(` 2. ${pc.cyan("pnpm install")}`); consola.info(` 3. ${pc.cyan("pnpm test")} # confirm the scaffold passes its own test`); consola.info(` 4. Edit ${pc.dim("src/plugin.ts")} to add routes and hooks.`); - consola.info(` 5. ${pc.cyan("emdash-registry bundle")} # when ready to publish`); + consola.info(` 5. ${pc.cyan("emdash-plugin bundle")} # when ready to publish`); } /** diff --git a/packages/registry-cli/src/commands/login.ts b/packages/plugin-cli/src/commands/login.ts similarity index 99% rename from packages/registry-cli/src/commands/login.ts rename to packages/plugin-cli/src/commands/login.ts index f924baa34..e7641d80d 100644 --- a/packages/registry-cli/src/commands/login.ts +++ b/packages/plugin-cli/src/commands/login.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry login ` + * `emdash-plugin login ` * * Interactive atproto OAuth login. Spins up a loopback HTTP server, opens the * user's browser at the AS authorization URL, awaits the callback, exchanges diff --git a/packages/registry-cli/src/commands/logout.ts b/packages/plugin-cli/src/commands/logout.ts similarity index 96% rename from packages/registry-cli/src/commands/logout.ts rename to packages/plugin-cli/src/commands/logout.ts index a4ef3c88f..a7bc9e3f4 100644 --- a/packages/registry-cli/src/commands/logout.ts +++ b/packages/plugin-cli/src/commands/logout.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry logout [--did ]` + * `emdash-plugin logout [--did ]` * * Revoke the active publisher session and remove its stored state. * diff --git a/packages/registry-cli/src/commands/publish.ts b/packages/plugin-cli/src/commands/publish.ts similarity index 99% rename from packages/registry-cli/src/commands/publish.ts rename to packages/plugin-cli/src/commands/publish.ts index 0e88eead5..4b080c782 100644 --- a/packages/registry-cli/src/commands/publish.ts +++ b/packages/plugin-cli/src/commands/publish.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry publish --url ` + * `emdash-plugin publish --url ` * * Thin citty wrapper around `publishRelease` from `../publish/api.js`. * @@ -120,7 +120,7 @@ export const publishCommand = defineCommand({ }, async run({ args }) { // In --json mode, stdout MUST contain only the final JSON object so - // callers can `emdash-registry publish ... --json | jq`. Route every + // callers can `emdash-plugin publish ... --json | jq`. Route every // consola log line to stderr; capture the previous reporter set so we // can restore in the finally below (matters when the CLI is exec'd // in-process by tests or wrappers). @@ -186,7 +186,7 @@ async function runPublish(args: PublishArgs): Promise { const session = await credentials.current(); if (!session) { throw new CliError( - "Not logged in. Run: emdash-registry login ", + "Not logged in. Run: emdash-plugin login ", 1, "NOT_LOGGED_IN", ); @@ -206,7 +206,7 @@ async function runPublish(args: PublishArgs): Promise { if (check.kind === "mismatch") { throw new CliError( `Manifest pins publisher to ${pc.bold(check.pinnedDisplay)} (${check.pinnedDid}), but the active session is ${session.did}. ` + - `Either switch sessions (\`emdash-registry switch ${check.pinnedDid}\`), or edit the manifest if you are transferring the plugin to a new publisher.`, + `Either switch sessions (\`emdash-plugin switch ${check.pinnedDid}\`), or edit the manifest if you are transferring the plugin to a new publisher.`, 1, "MANIFEST_PUBLISHER_MISMATCH", ); @@ -340,7 +340,7 @@ async function runPublish(args: PublishArgs): Promise { `The aggregator will pick this up from the firehose. To verify discovery once it's indexed:`, ); console.log( - ` ${pc.cyan(`emdash-registry info ${session.handle ?? session.did} ${result.slug}`)}`, + ` ${pc.cyan(`emdash-plugin info ${session.handle ?? session.did} ${result.slug}`)}`, ); } diff --git a/packages/registry-cli/src/commands/search.ts b/packages/plugin-cli/src/commands/search.ts similarity index 94% rename from packages/registry-cli/src/commands/search.ts rename to packages/plugin-cli/src/commands/search.ts index 46fb11c90..89bdb1299 100644 --- a/packages/registry-cli/src/commands/search.ts +++ b/packages/plugin-cli/src/commands/search.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry search [--capability ] [--limit ] [--cursor ]` + * `emdash-plugin search [--capability ] [--limit ] [--cursor ]` * * Free-text search the aggregator. Read-only; no auth required. */ @@ -82,7 +82,7 @@ export const searchCommand = defineCommand({ // 100, so suggesting "increase the limit" was misleading advice. consola.info( `More results available. Continue with: ${pc.cyan( - `emdash-registry search "${args.query}" --cursor ${result.cursor}`, + `emdash-plugin search "${args.query}" --cursor ${result.cursor}`, )}`, ); } diff --git a/packages/registry-cli/src/commands/switch.ts b/packages/plugin-cli/src/commands/switch.ts similarity index 87% rename from packages/registry-cli/src/commands/switch.ts rename to packages/plugin-cli/src/commands/switch.ts index 98c24cc9b..f777336fe 100644 --- a/packages/registry-cli/src/commands/switch.ts +++ b/packages/plugin-cli/src/commands/switch.ts @@ -1,9 +1,9 @@ /** - * `emdash-registry switch ` + * `emdash-plugin switch ` * * Change the active publisher session. The DID must already be in the * credentials store (i.e. you've previously logged in as it). Use - * `emdash-registry whoami` to see stored sessions. + * `emdash-plugin whoami` to see stored sessions. * * The OAuth library still resolves a refreshed access token by DID on the * next publish; this command only changes which DID is "current" for the @@ -38,7 +38,7 @@ export const switchCommand = defineCommand({ const target = await credentials.get(args.did); if (!target) { consola.error( - `No stored session for ${args.did}. Run: emdash-registry whoami to list stored sessions.`, + `No stored session for ${args.did}. Run: emdash-plugin whoami to list stored sessions.`, ); process.exit(1); } diff --git a/packages/registry-cli/src/commands/validate.ts b/packages/plugin-cli/src/commands/validate.ts similarity index 98% rename from packages/registry-cli/src/commands/validate.ts rename to packages/plugin-cli/src/commands/validate.ts index 09ef43072..a56b7bed0 100644 --- a/packages/registry-cli/src/commands/validate.ts +++ b/packages/plugin-cli/src/commands/validate.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry validate [path]` + * `emdash-plugin validate [path]` * * Validate an `emdash-plugin.jsonc` manifest against the v1 schema. * diff --git a/packages/registry-cli/src/commands/whoami.ts b/packages/plugin-cli/src/commands/whoami.ts similarity index 88% rename from packages/registry-cli/src/commands/whoami.ts rename to packages/plugin-cli/src/commands/whoami.ts index 81382fe3f..03eb7b964 100644 --- a/packages/registry-cli/src/commands/whoami.ts +++ b/packages/plugin-cli/src/commands/whoami.ts @@ -1,5 +1,5 @@ /** - * `emdash-registry whoami` + * `emdash-plugin whoami` * * Show the active publisher session, plus a short list of any other stored * sessions. Read-only: this command never refreshes tokens or hits the network. @@ -37,7 +37,7 @@ export const whoamiCommand = defineCommand({ } if (!current) { - consola.info("Not logged in. Run: emdash-registry login "); + consola.info("Not logged in. Run: emdash-plugin login "); return; } @@ -53,7 +53,7 @@ export const whoamiCommand = defineCommand({ console.log(` ${pc.dim(s.handle ?? s.did)} (${pc.dim(s.did)})`); } console.log(); - consola.info(`Switch with: ${pc.cyan("emdash-registry switch ")}`); + consola.info(`Switch with: ${pc.cyan("emdash-plugin switch ")}`); } }, }); diff --git a/packages/registry-cli/src/config.ts b/packages/plugin-cli/src/config.ts similarity index 100% rename from packages/registry-cli/src/config.ts rename to packages/plugin-cli/src/config.ts diff --git a/packages/plugin-cli/src/dev/command.ts b/packages/plugin-cli/src/dev/command.ts new file mode 100644 index 000000000..2e01cc9ed --- /dev/null +++ b/packages/plugin-cli/src/dev/command.ts @@ -0,0 +1,126 @@ +/** + * `emdash-plugin dev` + * + * Watch mode wrapper around `buildPlugin`. Rebuilds the plugin + * whenever `src/**`, `emdash-plugin.jsonc`, or `package.json` change. + * + * Behaviour: + * + * - Logs a divider + timestamp + result per rebuild. Doesn't clear + * the screen — authors keep a scrollback of what happened. + * - On error, prints the BuildError's structured code + message. + * Does *not* wipe `dist/` — the last successful build stays on + * disk so a downstream site importing the plugin keeps working + * until the next successful rebuild. + * - Debounces rapid bursts (editors saving multiple files) at + * 150ms so a single edit doesn't trigger several rebuilds. + * - SIGINT (Ctrl-C) closes the watcher cleanly and exits 0. + * + * Distinct from `build` only in that it loops + watches. The build + * pipeline itself is identical. + */ + +import { defineCommand } from "citty"; +import consola from "consola"; +import pc from "picocolors"; + +import { BuildError, buildPlugin, type BuildLogger } from "../build/api.js"; + +const DEBOUNCE_MS = 150; +const WATCH_GLOBS = ["src/**", "emdash-plugin.jsonc", "package.json"]; + +export const devCommand = defineCommand({ + meta: { + name: "dev", + description: "Watch a sandboxed plugin's sources and rebuild on change", + }, + args: { + dir: { + type: "string", + description: "Plugin directory (default: current directory)", + default: process.cwd(), + }, + outDir: { + type: "string", + alias: "o", + description: "Output directory (default: ./dist)", + default: "dist", + }, + }, + async run({ args }) { + const { default: chokidar } = await import("chokidar"); + + const logger: BuildLogger = { + start: (m) => consola.start(m), + info: (m) => consola.info(m), + success: (m) => consola.success(m), + warn: (m) => consola.warn(m), + }; + + const buildOnce = async (label: string): Promise => { + const stamp = new Date().toLocaleTimeString(); + console.log(); + console.log(pc.dim(`── ${label} at ${stamp} ─────────────────────`)); + try { + await buildPlugin({ + dir: args.dir, + outDir: args.outDir, + logger, + }); + } catch (error) { + if (error instanceof BuildError) { + consola.error(`${pc.bold(error.code)}: ${error.message}`); + } else { + consola.error(error instanceof Error ? error.message : String(error)); + } + consola.info(pc.dim("Last successful build (if any) is still in dist/. Waiting for changes...")); + } + }; + + // Initial build before starting the watcher. If it fails the watcher + // still starts so the author can fix the error and re-trigger. + await buildOnce("initial build"); + + const watcher = chokidar.watch(WATCH_GLOBS, { + cwd: args.dir, + ignoreInitial: true, + // Don't watch our own output — it'd loop. + ignored: ["dist/**", "**/node_modules/**"], + }); + + let timer: NodeJS.Timeout | undefined; + let pendingTrigger: string | undefined; + + const scheduleRebuild = (path: string) => { + pendingTrigger = path; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + const trigger = pendingTrigger ?? "change"; + pendingTrigger = undefined; + timer = undefined; + void buildOnce(`rebuild (${trigger})`); + }, DEBOUNCE_MS); + }; + + watcher.on("add", scheduleRebuild); + watcher.on("change", scheduleRebuild); + watcher.on("unlink", scheduleRebuild); + watcher.on("error", (error) => { + consola.error(`Watcher error: ${error instanceof Error ? error.message : String(error)}`); + }); + + consola.info(`Watching ${pc.cyan(args.dir)} for changes (Ctrl-C to stop)`); + + // SIGINT clean-up. Returns a promise that resolves when the user + // interrupts so we keep the watcher alive until then. + await new Promise((resolve) => { + const shutdown = () => { + consola.info("Stopping watcher..."); + if (timer) clearTimeout(timer); + void watcher.close().then(() => resolve()); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); + }, +}); diff --git a/packages/registry-cli/src/index.ts b/packages/plugin-cli/src/index.ts similarity index 86% rename from packages/registry-cli/src/index.ts rename to packages/plugin-cli/src/index.ts index 252228062..c9691dabf 100644 --- a/packages/registry-cli/src/index.ts +++ b/packages/plugin-cli/src/index.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node /** - * @emdash-cms/registry-cli + * @emdash-cms/plugin-cli * - * CLI for the experimental EmDash plugin registry. Entry point: `emdash-registry`. + * CLI for the experimental EmDash plugin registry. Entry point: `emdash-plugin`. * * Subcommands: * - login — interactive atproto OAuth login @@ -12,6 +12,8 @@ * - search — free-text search the aggregator * - info — show details about a package * - init — scaffold a new sandboxed plugin + * - build — produce the npm distribution artifacts (dist/index.mjs, dist/plugin.mjs, dist/manifest.json) + * - dev — watch sources and rebuild on change * - bundle — bundle a plugin source directory into a tarball * - publish — publish a release that points at a hosted tarball * - validate — validate an emdash-plugin.jsonc manifest against the v1 schema @@ -22,7 +24,9 @@ import { defineCommand, runMain } from "citty"; +import { buildCommand } from "./build/command.js"; import { bundleCommand } from "./bundle/command.js"; +import { devCommand } from "./dev/command.js"; import { infoCommand } from "./commands/info.js"; import { initCommand } from "./commands/init.js"; import { loginCommand } from "./commands/login.js"; @@ -35,7 +39,7 @@ import { whoamiCommand } from "./commands/whoami.js"; const main = defineCommand({ meta: { - name: "emdash-registry", + name: "emdash-plugin", description: "CLI for the experimental EmDash plugin registry", }, subCommands: { @@ -46,6 +50,8 @@ const main = defineCommand({ search: searchCommand, info: infoCommand, init: initCommand, + build: buildCommand, + dev: devCommand, bundle: bundleCommand, publish: publishCommand, validate: validateCommand, diff --git a/packages/registry-cli/src/init/environment.ts b/packages/plugin-cli/src/init/environment.ts similarity index 99% rename from packages/registry-cli/src/init/environment.ts rename to packages/plugin-cli/src/init/environment.ts index 2acba5f9e..8cebcc264 100644 --- a/packages/registry-cli/src/init/environment.ts +++ b/packages/plugin-cli/src/init/environment.ts @@ -1,5 +1,5 @@ /** - * Environment-probe helpers for `emdash-registry init`. + * Environment-probe helpers for `emdash-plugin init`. * * The goal: when the user runs init, pre-fill prompts with whatever the * surrounding environment already knows. None of these probes are diff --git a/packages/registry-cli/src/init/scaffold.ts b/packages/plugin-cli/src/init/scaffold.ts similarity index 98% rename from packages/registry-cli/src/init/scaffold.ts rename to packages/plugin-cli/src/init/scaffold.ts index 058df5346..96f6cabe2 100644 --- a/packages/registry-cli/src/init/scaffold.ts +++ b/packages/plugin-cli/src/init/scaffold.ts @@ -1,5 +1,5 @@ /** - * Filesystem half of `emdash-registry init`. Takes the scaffold inputs + + * Filesystem half of `emdash-plugin init`. Takes the scaffold inputs + * the target directory and writes the file tree. Pure templates live in * `./templates.ts` so this module is just policy: which files exist, * where they go, what happens when something's already there. diff --git a/packages/registry-cli/src/init/templates.ts b/packages/plugin-cli/src/init/templates.ts similarity index 93% rename from packages/registry-cli/src/init/templates.ts rename to packages/plugin-cli/src/init/templates.ts index 064646ee0..2ad57bf57 100644 --- a/packages/registry-cli/src/init/templates.ts +++ b/packages/plugin-cli/src/init/templates.ts @@ -1,5 +1,5 @@ /** - * Pure file-content producers for `emdash-registry init`. + * Pure file-content producers for `emdash-plugin init`. * * No filesystem access here — each function takes the inputs and returns * the bytes that should land at the target path. Keeping these as pure @@ -17,7 +17,7 @@ * tests/plugin.test.ts * * No `src/index.ts`, no `dist/`, no tsdown. Source is the artefact; - * `emdash-registry bundle` transpiles at publish time. + * `emdash-plugin bundle` transpiles at publish time. */ import type { ManifestAuthor, ManifestSecurityContact } from "../manifest/schema.js"; @@ -90,7 +90,7 @@ export function renderManifest(input: ScaffoldInputs): string { const lines: string[] = []; lines.push("{"); lines.push( - '\t"$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json",', + '\t"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",', ); lines.push(""); lines.push(`\t"slug": ${jsonString(input.slug)},`); @@ -98,7 +98,7 @@ export function renderManifest(input: ScaffoldInputs): string { if (!input.publisher) { lines.push( - '\t// TODO: set your atproto handle (e.g. "example.com") or DID before running `emdash-registry bundle` or any local-dev integration. The plugin cannot load without it.', + '\t// TODO: set your atproto handle (e.g. "example.com") or DID before running `emdash-plugin bundle` or any local-dev integration. The plugin cannot load without it.', ); lines.push('\t"publisher": "",'); } else { @@ -278,18 +278,18 @@ pnpm typecheck pnpm test \`\`\` -To test against a running EmDash site, link this plugin into the site's -workspace and reference it from the integration's plugin loader. (Local- -dev wiring details land alongside the \`localPlugin\` helper from -\`@emdash-cms/registry-cli/dev\`.) +To test against a running EmDash site, run \`pnpm dev\` in this +directory (rebuilds on save) and \`pnpm add file:../path/to/this\` +in the site. Then \`import ${title} from "${input.slug}"\` and pass +it into \`emdash({ sandboxed: [${title}] })\`. ## Publish \`\`\`sh -emdash-registry login # if you're not already logged in -emdash-registry bundle # produces dist/${title}-.tar.gz +emdash-plugin login # if you're not already logged in +emdash-plugin bundle # produces dist/${title}-.tar.gz # upload that tarball to a public URL, then: -emdash-registry publish --url https://your-host/... +emdash-plugin publish --url https://your-host/... \`\`\` ## Version bumps diff --git a/packages/registry-cli/src/manifest/load.ts b/packages/plugin-cli/src/manifest/load.ts similarity index 99% rename from packages/registry-cli/src/manifest/load.ts rename to packages/plugin-cli/src/manifest/load.ts index fe3ad6541..32128dda7 100644 --- a/packages/registry-cli/src/manifest/load.ts +++ b/packages/plugin-cli/src/manifest/load.ts @@ -15,7 +15,7 @@ * value's location in the source where possible. * * The line/column mapping is critical for editor-side workflows: a user - * running `emdash-registry validate` from a CI step wants the same kind of + * running `emdash-plugin validate` from a CI step wants the same kind of * pointer they'd get from `tsc` or `eslint`, not a Zod issue tree. */ @@ -139,7 +139,7 @@ async function readBoundedUtf8(filePath: string): Promise { if (isNodeNotFoundError(error)) { throw new ManifestError( "MANIFEST_NOT_FOUND", - `No manifest at ${filePath}. Create one with: emdash-registry init`, + `No manifest at ${filePath}. Create one with: emdash-plugin init`, filePath, ); } diff --git a/packages/registry-cli/src/manifest/publisher.ts b/packages/plugin-cli/src/manifest/publisher.ts similarity index 99% rename from packages/registry-cli/src/manifest/publisher.ts rename to packages/plugin-cli/src/manifest/publisher.ts index b799cb44d..65cce4344 100644 --- a/packages/registry-cli/src/manifest/publisher.ts +++ b/packages/plugin-cli/src/manifest/publisher.ts @@ -6,7 +6,7 @@ * * 1. Manifest pins a publisher (DID or handle). * - DID: compare verbatim against the session DID. Mismatch is an - * immediate, no-override error. The user must `emdash-registry switch` + * immediate, no-override error. The user must `emdash-plugin switch` * to the right session, or edit the manifest if they're transferring * the plugin. * - Handle: resolve to a DID via `@atcute/identity-resolver`, then diff --git a/packages/registry-cli/src/manifest/schema.ts b/packages/plugin-cli/src/manifest/schema.ts similarity index 97% rename from packages/registry-cli/src/manifest/schema.ts rename to packages/plugin-cli/src/manifest/schema.ts index 0cede01ad..ca4b087db 100644 --- a/packages/registry-cli/src/manifest/schema.ts +++ b/packages/plugin-cli/src/manifest/schema.ts @@ -557,7 +557,16 @@ export const ManifestSchema = z // Identity. Slug + publisher together form the package's identity; // the AT URI is derived at runtime, never authored. slug: SlugSchema, - version: VersionSchema, + // Version is optional in the source manifest. `package.json#version` + // is the canonical source for npm-distributed plugins (Changesets + // bumps it on release), so duplicating it here causes drift. Two + // authoring shapes are valid: + // - Omit `version` in the manifest, keep it only in `package.json`. + // - Set it in both, in which case the build step enforces they + // match and errors loudly on mismatch. + // Registry-only plugins (no `package.json`) must set `version` here + // — there's nowhere else for it to live. + version: VersionSchema.optional(), // Required on first publish, ignored on subsequent publishes (the // existing profile wins). Same precedence rules as today's diff --git a/packages/registry-cli/src/manifest/translate.ts b/packages/plugin-cli/src/manifest/translate.ts similarity index 57% rename from packages/registry-cli/src/manifest/translate.ts rename to packages/plugin-cli/src/manifest/translate.ts index c47099d0b..ba5c9f8e6 100644 --- a/packages/registry-cli/src/manifest/translate.ts +++ b/packages/plugin-cli/src/manifest/translate.ts @@ -28,7 +28,10 @@ export interface NormalisedAdmin { } export interface NormalisedManifest { - // Identity (required). + // Identity. All three are guaranteed present in the normalised + // form: `slug` and `publisher` are required at authoring time, + // and `version` is resolved during normalisation from the manifest + // or `package.json#version` (with a mismatch / missing check). slug: string; version: string; publisher: string; @@ -55,21 +58,95 @@ export interface NormalisedManifest { admin: NormalisedAdmin; } +/** + * Thrown when the source manifest and the package's `package.json` carry + * different versions, or when neither carries one. Callers convert this + * into their own error code (BuildError, BundleError, ManifestError). + */ +export class VersionMismatchError extends Error { + override readonly name = "VersionMismatchError"; + readonly code: "VERSION_MISMATCH" | "VERSION_MISSING"; + readonly manifestVersion: string | undefined; + readonly packageVersion: string | undefined; + + constructor( + code: "VERSION_MISMATCH" | "VERSION_MISSING", + message: string, + manifestVersion: string | undefined, + packageVersion: string | undefined, + ) { + super(message); + this.code = code; + this.manifestVersion = manifestVersion; + this.packageVersion = packageVersion; + } +} + +/** + * Reconcile the manifest's `version` with the package's `version`. + * + * - Both present and equal → returns that string. + * - Both present and different → throws `VERSION_MISMATCH`. + * - Only one present → returns it. + * - Neither present → throws `VERSION_MISSING`. + * + * The "Changesets-driven" common case (manifest omits `version`, + * `package.json` carries it) and the "registry-only, no package.json" + * case (manifest carries `version`, no `package.json`) both produce a + * single source of truth without ceremony. The mismatch case is the + * one we want to fail loudly on — silent prefer-one creates drift. + */ +export function resolvePluginVersion( + manifestVersion: string | undefined, + packageVersion: string | undefined, +): string { + if (manifestVersion !== undefined && packageVersion !== undefined) { + if (manifestVersion !== packageVersion) { + throw new VersionMismatchError( + "VERSION_MISMATCH", + `Plugin version disagrees between emdash-plugin.jsonc (${manifestVersion}) and package.json (${packageVersion}). Remove "version" from emdash-plugin.jsonc to let package.json drive it, or align both values.`, + manifestVersion, + packageVersion, + ); + } + return manifestVersion; + } + if (manifestVersion !== undefined) return manifestVersion; + if (packageVersion !== undefined) return packageVersion; + throw new VersionMismatchError( + "VERSION_MISSING", + 'Plugin version not set. Add "version" to package.json (npm-distributed plugins) or to emdash-plugin.jsonc (registry-only plugins).', + manifestVersion, + packageVersion, + ); +} + /** * Collapse the convenience forms (`author`, `security`) into the array - * forms (`authors`, `securityContacts`). + * forms (`authors`, `securityContacts`), and reconcile the manifest's + * optional `version` against the package's `version` so callers see a + * single resolved string. * * The manifest schema's `.refine()` rules already guarantee that exactly - * one of each pair is set, so the runtime checks here are defensive — a - * caller that bypassed validation would still produce a coherent result. + * one of each name/contact pair is set, so the runtime checks here are + * defensive — a caller that bypassed validation would still produce a + * coherent result. + * + * Pass `packageVersion: undefined` for registry-only plugins with no + * `package.json` — in that case the manifest's `version` is used + * directly (and is required, by the same `resolvePluginVersion` rules). */ -export function normaliseManifest(manifest: Manifest): NormalisedManifest { +export function normaliseManifest( + manifest: Manifest, + packageVersion?: string, +): NormalisedManifest { const authors = manifest.authors ?? (manifest.author ? [manifest.author] : []); const securityContacts = manifest.securityContacts ?? (manifest.security ? [manifest.security] : []); + const version = resolvePluginVersion(manifest.version, packageVersion); return { slug: manifest.slug, - version: manifest.version, + version, publisher: manifest.publisher, license: manifest.license, authors, diff --git a/packages/registry-cli/src/multihash.ts b/packages/plugin-cli/src/multihash.ts similarity index 100% rename from packages/registry-cli/src/multihash.ts rename to packages/plugin-cli/src/multihash.ts diff --git a/packages/registry-cli/src/oauth.ts b/packages/plugin-cli/src/oauth.ts similarity index 100% rename from packages/registry-cli/src/oauth.ts rename to packages/plugin-cli/src/oauth.ts diff --git a/packages/registry-cli/src/profile.ts b/packages/plugin-cli/src/profile.ts similarity index 100% rename from packages/registry-cli/src/profile.ts rename to packages/plugin-cli/src/profile.ts diff --git a/packages/registry-cli/src/publish/api.ts b/packages/plugin-cli/src/publish/api.ts similarity index 100% rename from packages/registry-cli/src/publish/api.ts rename to packages/plugin-cli/src/publish/api.ts diff --git a/packages/registry-cli/tests/bundle-utils.test.ts b/packages/plugin-cli/tests/bundle-utils.test.ts similarity index 100% rename from packages/registry-cli/tests/bundle-utils.test.ts rename to packages/plugin-cli/tests/bundle-utils.test.ts diff --git a/packages/registry-cli/tests/bundle.test.ts b/packages/plugin-cli/tests/bundle.test.ts similarity index 93% rename from packages/registry-cli/tests/bundle.test.ts rename to packages/plugin-cli/tests/bundle.test.ts index 5a3475b1f..632e8c8c1 100644 --- a/packages/registry-cli/tests/bundle.test.ts +++ b/packages/plugin-cli/tests/bundle.test.ts @@ -47,7 +47,7 @@ describe("bundlePlugin", () => { const manifest = result.manifest; // Plain hook name (defaults). - expect(manifest.hooks).toContain("content:beforeCreate"); + expect(manifest.hooks).toContain("content:beforeSave"); // Routes are extracted from src/plugin.ts's default export. expect(manifest.routes).toContain("admin"); }); @@ -140,18 +140,23 @@ describe("bundlePlugin", () => { }); it("validateOnly bundles never write the tarball even if outDir exists", async () => { - // outDir already exists from beforeEach; validateOnly must not put a - // tarball into it. + // validateOnly skips tarball creation but still produces the + // build artifacts in dist/. Tarball-specific checks (`.tar.gz` + // presence) are what we're asserting here, not "dist is empty". const result = await bundlePlugin({ dir: FIXTURE, outDir, validateOnly: true, }); expect(result.tarballPath).toBeNull(); + expect(result.tarballBytes).toBeNull(); + expect(result.sha256).toBeNull(); const fs = await import("node:fs/promises"); const contents = await fs.readdir(outDir); - expect(contents).toEqual([]); + // Dist artifacts ARE expected (build runs unconditionally), but no + // tarball. + expect(contents.some((f) => f.endsWith(".tar.gz"))).toBe(false); }); it("hard-fails when the plugin has a manifest but no src/plugin.ts", async () => { diff --git a/packages/registry-cli/tests/config.test.ts b/packages/plugin-cli/tests/config.test.ts similarity index 100% rename from packages/registry-cli/tests/config.test.ts rename to packages/plugin-cli/tests/config.test.ts diff --git a/packages/registry-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc b/packages/plugin-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc similarity index 100% rename from packages/registry-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc rename to packages/plugin-cli/tests/fixtures/bad-plugin/emdash-plugin.jsonc diff --git a/packages/registry-cli/tests/fixtures/bad-plugin/package.json b/packages/plugin-cli/tests/fixtures/bad-plugin/package.json similarity index 100% rename from packages/registry-cli/tests/fixtures/bad-plugin/package.json rename to packages/plugin-cli/tests/fixtures/bad-plugin/package.json diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc b/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc similarity index 100% rename from packages/registry-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc rename to packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/package.json b/packages/plugin-cli/tests/fixtures/minimal-plugin/package.json similarity index 100% rename from packages/registry-cli/tests/fixtures/minimal-plugin/package.json rename to packages/plugin-cli/tests/fixtures/minimal-plugin/package.json diff --git a/packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts b/packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts new file mode 100644 index 000000000..ad9e71450 --- /dev/null +++ b/packages/plugin-cli/tests/fixtures/minimal-plugin/src/plugin.ts @@ -0,0 +1,22 @@ +/** + * Test fixture: minimal sandbox entry. Exports a default object with hooks + * and routes so the bundler's probe captures shape into the manifest. + * + * Uses the new authoring shape: bare default export with a + * `satisfies SandboxedPlugin` annotation. The import is type-only, so + * the bundler erases it — no runtime resolution of `emdash/plugin` + * needed. + */ +import type { SandboxedPlugin } from "emdash/plugin"; + +// `content:beforeCreate` isn't in the strict mapped type, so use the +// canonical content hook name. Test assertions also expect `content:beforeSave` +// via the runtime hook vocabulary. +export default { + hooks: { + "content:beforeSave": (event) => Promise.resolve(event.content), + }, + routes: { + admin: () => Promise.resolve(new Response("ok")), + }, +} satisfies SandboxedPlugin; diff --git a/packages/registry-cli/tests/init-environment.test.ts b/packages/plugin-cli/tests/init-environment.test.ts similarity index 100% rename from packages/registry-cli/tests/init-environment.test.ts rename to packages/plugin-cli/tests/init-environment.test.ts diff --git a/packages/registry-cli/tests/init-scaffold.test.ts b/packages/plugin-cli/tests/init-scaffold.test.ts similarity index 100% rename from packages/registry-cli/tests/init-scaffold.test.ts rename to packages/plugin-cli/tests/init-scaffold.test.ts diff --git a/packages/registry-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts similarity index 98% rename from packages/registry-cli/tests/init-templates.test.ts rename to packages/plugin-cli/tests/init-templates.test.ts index 0bd30bb64..227053f2b 100644 --- a/packages/registry-cli/tests/init-templates.test.ts +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -91,7 +91,7 @@ describe("renderManifest (fully-populated)", () => { it("includes the $schema reference for IDE completion", () => { const source = renderManifest(FULL_INPUTS); expect(source).toContain( - '"$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json"', + '"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json"', ); }); @@ -235,8 +235,8 @@ describe("renderGitignore", () => { describe("renderReadme", () => { it("documents the publish path", () => { const source = renderReadme(FULL_INPUTS); - expect(source).toContain("emdash-registry bundle"); - expect(source).toContain("emdash-registry publish"); + expect(source).toContain("emdash-plugin bundle"); + expect(source).toContain("emdash-plugin publish"); }); it("documents version-bump rules for the trust contract", () => { diff --git a/packages/registry-cli/tests/manifest-load.test.ts b/packages/plugin-cli/tests/manifest-load.test.ts similarity index 99% rename from packages/registry-cli/tests/manifest-load.test.ts rename to packages/plugin-cli/tests/manifest-load.test.ts index 78cc56f40..d2e6e5179 100644 --- a/packages/registry-cli/tests/manifest-load.test.ts +++ b/packages/plugin-cli/tests/manifest-load.test.ts @@ -5,7 +5,7 @@ * 1. JSONC tolerance: trailing commas + comments are accepted (matches * the wrangler.jsonc / tsconfig.json convention). * 2. Source locations on validation errors: the error path is mapped - * back to a 1-indexed line:column so `emdash-registry validate` + * back to a 1-indexed line:column so `emdash-plugin validate` * points editors at the offending field. * * The tests below use `parseAndValidateManifest` (the in-memory variant) diff --git a/packages/registry-cli/tests/manifest-publisher.test.ts b/packages/plugin-cli/tests/manifest-publisher.test.ts similarity index 100% rename from packages/registry-cli/tests/manifest-publisher.test.ts rename to packages/plugin-cli/tests/manifest-publisher.test.ts diff --git a/packages/registry-cli/tests/manifest-schema.test.ts b/packages/plugin-cli/tests/manifest-schema.test.ts similarity index 96% rename from packages/registry-cli/tests/manifest-schema.test.ts rename to packages/plugin-cli/tests/manifest-schema.test.ts index f7c689a95..c90f035a1 100644 --- a/packages/registry-cli/tests/manifest-schema.test.ts +++ b/packages/plugin-cli/tests/manifest-schema.test.ts @@ -7,7 +7,7 @@ * field so a future field add lands cleanly alongside its own test block. * * Where applicable, tests assert on the EXACT Zod issue path / message - * because those strings surface in `emdash-registry validate` output -- + * because those strings surface in `emdash-plugin validate` output -- * users see them, and silently changing them breaks anyone who built * tooling around the strings. */ @@ -139,7 +139,7 @@ describe("ManifestSchema (full document)", () => { it("accepts a manifest with $schema for IDE completion", () => { const result = ManifestSchema.safeParse({ ...minimal, - $schema: "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", + $schema: "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", }); expect(result.success).toBe(true); }); @@ -239,7 +239,7 @@ describe("ManifestSchema (full document)", () => { it("accepts a full populated manifest", () => { const result = ManifestSchema.safeParse({ - $schema: "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", + $schema: "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", slug: "gallery", version: "0.1.0", publisher: "example.com", diff --git a/packages/registry-cli/tests/manifest-translate.test.ts b/packages/plugin-cli/tests/manifest-translate.test.ts similarity index 58% rename from packages/registry-cli/tests/manifest-translate.test.ts rename to packages/plugin-cli/tests/manifest-translate.test.ts index 7f8818f76..3570990ec 100644 --- a/packages/registry-cli/tests/manifest-translate.test.ts +++ b/packages/plugin-cli/tests/manifest-translate.test.ts @@ -13,6 +13,7 @@ import { describe("normaliseManifest", () => { it("collapses single-author into authors[]", () => { const normalised = normaliseManifest({ + version: "0.1.0", license: "MIT", author: { name: "Jane" }, security: { email: "s@example.com" }, @@ -24,6 +25,7 @@ describe("normaliseManifest", () => { it("passes the multi-author array through unchanged", () => { const normalised = normaliseManifest({ + version: "0.1.0", license: "MIT", authors: [{ name: "A" }, { name: "B" }], securityContacts: [{ email: "s@example.com" }], @@ -33,6 +35,7 @@ describe("normaliseManifest", () => { it("propagates publisher when set", () => { const normalised = normaliseManifest({ + version: "0.1.0", license: "MIT", publisher: "did:plc:abc", author: { name: "Jane" }, @@ -40,11 +43,72 @@ describe("normaliseManifest", () => { }); expect(normalised.publisher).toBe("did:plc:abc"); }); + + it("uses package.json version when manifest omits it", () => { + const normalised = normaliseManifest( + { + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }, + "1.2.3", + ); + expect(normalised.version).toBe("1.2.3"); + }); + + it("uses manifest version when no package.json version is provided", () => { + const normalised = normaliseManifest({ + version: "0.9.0", + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }); + expect(normalised.version).toBe("0.9.0"); + }); + + it("accepts matching versions from both sources", () => { + const normalised = normaliseManifest( + { + version: "2.0.0", + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }, + "2.0.0", + ); + expect(normalised.version).toBe("2.0.0"); + }); + + it("throws on mismatched versions", () => { + expect(() => + normaliseManifest( + { + version: "1.0.0", + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }, + "2.0.0", + ), + ).toThrow(/disagrees/); + }); + + it("throws when no version is available anywhere", () => { + expect(() => + normaliseManifest({ + license: "MIT", + author: { name: "Jane" }, + security: { email: "s@example.com" }, + }), + ).toThrow(/not set/); + }); }); describe("manifestToProfileBootstrap", () => { it("maps the publish-relevant subset of fields", () => { const normalised: NormalisedManifest = { + slug: "test", + version: "0.1.0", license: "MIT", publisher: "did:plc:abc", authors: [{ name: "Jane", url: "https://example.com" }], @@ -53,6 +117,10 @@ describe("manifestToProfileBootstrap", () => { description: "desc", keywords: ["k"], repo: "https://github.com/example/p", + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { pages: [], widgets: [] }, }; const bootstrap = manifestToProfileBootstrap(normalised); expect(bootstrap.license).toBe("MIT"); @@ -63,14 +131,20 @@ describe("manifestToProfileBootstrap", () => { it("uses the first author when multiple are provided", () => { const normalised: NormalisedManifest = { + slug: "test", + version: "0.1.0", license: "MIT", - publisher: undefined, + publisher: "did:plc:abc", authors: [{ name: "First" }, { name: "Second" }], securityContacts: [{ email: "s@example.com" }], name: undefined, description: undefined, keywords: undefined, repo: undefined, + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { pages: [], widgets: [] }, }; const bootstrap = manifestToProfileBootstrap(normalised); expect(bootstrap.authorName).toBe("First"); diff --git a/packages/registry-cli/tests/manifest-trust-contract.test.ts b/packages/plugin-cli/tests/manifest-trust-contract.test.ts similarity index 100% rename from packages/registry-cli/tests/manifest-trust-contract.test.ts rename to packages/plugin-cli/tests/manifest-trust-contract.test.ts diff --git a/packages/registry-cli/tests/mock-pds.ts b/packages/plugin-cli/tests/mock-pds.ts similarity index 100% rename from packages/registry-cli/tests/mock-pds.ts rename to packages/plugin-cli/tests/mock-pds.ts diff --git a/packages/registry-cli/tests/multihash.test.ts b/packages/plugin-cli/tests/multihash.test.ts similarity index 100% rename from packages/registry-cli/tests/multihash.test.ts rename to packages/plugin-cli/tests/multihash.test.ts diff --git a/packages/registry-cli/tests/publish-tarball.test.ts b/packages/plugin-cli/tests/publish-tarball.test.ts similarity index 100% rename from packages/registry-cli/tests/publish-tarball.test.ts rename to packages/plugin-cli/tests/publish-tarball.test.ts diff --git a/packages/registry-cli/tests/publish.test.ts b/packages/plugin-cli/tests/publish.test.ts similarity index 100% rename from packages/registry-cli/tests/publish.test.ts rename to packages/plugin-cli/tests/publish.test.ts diff --git a/packages/registry-cli/tests/schema-drift.test.ts b/packages/plugin-cli/tests/schema-drift.test.ts similarity index 94% rename from packages/registry-cli/tests/schema-drift.test.ts rename to packages/plugin-cli/tests/schema-drift.test.ts index 01882686b..4dc3ebb88 100644 --- a/packages/registry-cli/tests/schema-drift.test.ts +++ b/packages/plugin-cli/tests/schema-drift.test.ts @@ -3,7 +3,7 @@ * JSON Schema at `schemas/emdash-plugin.schema.json`. * * The committed JSON Schema is shipped to users via - * `node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json` + * `node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json` * so editors can offer completion and validation without running our CLI. * If a contributor changes the Zod schema and forgets to regenerate, this * test fails with a clear "run pnpm gen-schema" instruction. @@ -51,7 +51,7 @@ describe("JSON Schema drift", () => { if (committed !== regenerated) { throw new Error( "schemas/emdash-plugin.schema.json is out of date with the Zod schema.\n" + - "Run: pnpm --filter @emdash-cms/registry-cli gen-schema\n" + + "Run: pnpm --filter @emdash-cms/plugin-cli gen-schema\n" + "Then commit the result.", ); } diff --git a/packages/registry-cli/tests/url-validation.test.ts b/packages/plugin-cli/tests/url-validation.test.ts similarity index 100% rename from packages/registry-cli/tests/url-validation.test.ts rename to packages/plugin-cli/tests/url-validation.test.ts diff --git a/packages/registry-cli/tsconfig.json b/packages/plugin-cli/tsconfig.json similarity index 100% rename from packages/registry-cli/tsconfig.json rename to packages/plugin-cli/tsconfig.json diff --git a/packages/registry-cli/tsdown.config.ts b/packages/plugin-cli/tsdown.config.ts similarity index 89% rename from packages/registry-cli/tsdown.config.ts rename to packages/plugin-cli/tsdown.config.ts index 5378919d5..84b62f6c3 100644 --- a/packages/registry-cli/tsdown.config.ts +++ b/packages/plugin-cli/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig([ - // CLI binary: `emdash-registry`. Bundled to a single .mjs. + // CLI binary: `emdash-plugin`. Bundled to a single .mjs. { entry: ["src/index.ts"], format: ["esm"], @@ -15,7 +15,7 @@ export default defineConfig([ // Programmatic API entry. With tsdown's ESM defaults this emits // `.mjs` + `.d.mts` (matching the `exports` field in package.json). { - entry: ["src/api.ts", "src/dev.ts"], + entry: ["src/api.ts"], format: ["esm"], dts: true, clean: false, @@ -31,6 +31,7 @@ export default defineConfig([ "@emdash-cms/registry-client", "@emdash-cms/registry-lexicons", "@oslojs/crypto", + "chokidar", "citty", "consola", "image-size", diff --git a/packages/registry-cli/vitest.config.ts b/packages/plugin-cli/vitest.config.ts similarity index 100% rename from packages/registry-cli/vitest.config.ts rename to packages/plugin-cli/vitest.config.ts diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 13b3193d1..8255885fc 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -8,7 +8,7 @@ * - **`emdash` (core)** reads `manifest.json` at install time and again at * runtime when gating a sandboxed plugin's access to capabilities. Core * is the contract reader. - * - **`@emdash-cms/registry-cli`** writes `manifest.json` during bundling + * - **`@emdash-cms/plugin-cli`** writes `manifest.json` during bundling * (extracted from the plugin author's source) and publishes the resulting * records via atproto. registry-cli is the contract writer. * diff --git a/packages/plugins/atproto/emdash-plugin.jsonc b/packages/plugins/atproto/emdash-plugin.jsonc index 42ee5fe81..9b9829c6e 100644 --- a/packages/plugins/atproto/emdash-plugin.jsonc +++ b/packages/plugins/atproto/emdash-plugin.jsonc @@ -1,8 +1,7 @@ { - "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", "slug": "atproto", - "version": "0.1.3", "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com "license": "MIT", diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index 5d5dc6bd3..5f831b72a 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -3,11 +3,15 @@ "version": "0.1.3", "description": "AT Protocol / standard.site syndication plugin for EmDash CMS", "type": "module", - "private": true, - "scripts": { - "test": "vitest run", - "typecheck": "tsgo --noEmit" + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" }, + "files": ["dist", "emdash-plugin.jsonc"], "keywords": [ "emdash", "cms", @@ -20,14 +24,22 @@ ], "author": "Matt Kane", "license": "MIT", - "dependencies": { - "emdash": "workspace:*" + "peerDependencies": { + "emdash": "workspace:>=0.10.0" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", "jsonc-parser": "catalog:", + "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" }, + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", + "test": "vitest run", + "typecheck": "tsgo --noEmit" + }, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/atproto/src/plugin.ts b/packages/plugins/atproto/src/plugin.ts index d5e4ffd81..43c1c44f1 100644 --- a/packages/plugins/atproto/src/plugin.ts +++ b/packages/plugins/atproto/src/plugin.ts @@ -6,8 +6,7 @@ * bluesky.ts, and standard-site.ts into a single self-contained file. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; import { getAdminPageTarget, type AdminInteraction } from "./admin-routing.js"; import { @@ -327,7 +326,7 @@ async function syndicatePublishedContent( // ── Plugin definition ─────────────────────────────────────────── -export default definePlugin({ +export default { hooks: { "plugin:install": async (_event: unknown, ctx: PluginContext) => { ctx.log.info("AT Protocol plugin installed"); @@ -525,7 +524,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit admin helpers ───────────────────────────────────── diff --git a/packages/plugins/audit-log/emdash-plugin.jsonc b/packages/plugins/audit-log/emdash-plugin.jsonc index e4d986f90..32f779f9f 100644 --- a/packages/plugins/audit-log/emdash-plugin.jsonc +++ b/packages/plugins/audit-log/emdash-plugin.jsonc @@ -1,8 +1,7 @@ { - "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", "slug": "audit-log", - "version": "0.1.3", "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com "license": "MIT", diff --git a/packages/plugins/audit-log/package.json b/packages/plugins/audit-log/package.json index efb9841cd..5561811cd 100644 --- a/packages/plugins/audit-log/package.json +++ b/packages/plugins/audit-log/package.json @@ -3,19 +3,31 @@ "version": "0.1.3", "description": "Audit logging plugin for EmDash CMS - tracks content changes", "type": "module", - "private": true, - "scripts": { - "typecheck": "tsgo --noEmit" + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" }, + "files": ["dist", "emdash-plugin.jsonc"], "keywords": ["emdash", "cms", "plugin", "audit", "logging", "history"], "author": "Matt Kane", "license": "MIT", - "dependencies": { - "emdash": "workspace:*" + "peerDependencies": { + "emdash": "workspace:>=0.10.0" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", + "tsdown": "catalog:", "typescript": "catalog:" }, + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", + "typecheck": "tsgo --noEmit" + }, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/audit-log/src/plugin.ts b/packages/plugins/audit-log/src/plugin.ts index 626cc1ad7..cf4b0d5e7 100644 --- a/packages/plugins/audit-log/src/plugin.ts +++ b/packages/plugins/audit-log/src/plugin.ts @@ -10,28 +10,7 @@ * degradation -- the entry is still recorded). */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -interface ContentSaveEvent { - content: Record & { - id?: string | number; - slug?: string; - status?: string; - data?: Record; - }; - collection: string; - isNew: boolean; -} - -interface ContentDeleteEvent { - id: string; - collection: string; -} - -interface MediaUploadEvent { - media: { id: string }; -} +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface AuditEntry { timestamp: string; @@ -69,26 +48,26 @@ const beforeSaveCache = new Map(); // ── Plugin definition ── -export default definePlugin({ +export default { hooks: { - "plugin:install": async (_event: unknown, ctx: PluginContext) => { + "plugin:install": async (_event, ctx) => { ctx.log.info("Audit log plugin installed"); }, - "plugin:activate": async (_event: unknown, ctx: PluginContext) => { + "plugin:activate": async (_event, ctx) => { ctx.log.info("Audit log plugin activated"); }, - "plugin:deactivate": async (_event: unknown, ctx: PluginContext) => { + "plugin:deactivate": async (_event, ctx) => { ctx.log.info("Audit log plugin deactivated"); }, - "plugin:uninstall": async (_event: unknown, ctx: PluginContext) => { + "plugin:uninstall": async (_event, ctx) => { ctx.log.info("Audit log plugin uninstalled"); }, "content:beforeSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { if (!event.isNew && event.content.id) { const contentId = typeof event.content.id === "string" ? event.content.id : String(event.content.id); @@ -108,7 +87,7 @@ export default definePlugin({ }, "content:afterSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const contentId = typeof event.content.id === "string" ? event.content.id : String(event.content.id ?? ""); const cacheKey = `${event.collection}:${contentId}`; @@ -141,7 +120,7 @@ export default definePlugin({ }, "content:beforeDelete": { - handler: async (event: ContentDeleteEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { if (ctx.content) { try { const existing = await ctx.content.get(event.collection, event.id); @@ -157,7 +136,7 @@ export default definePlugin({ }, "content:afterDelete": { - handler: async (event: ContentDeleteEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const cacheKey = `delete:${event.collection}:${event.id}`; const beforeData = beforeSaveCache.get(cacheKey); beforeSaveCache.delete(cacheKey); @@ -183,7 +162,7 @@ export default definePlugin({ }, "media:afterUpload": { - handler: async (event: MediaUploadEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const entry: AuditEntry = { timestamp: new Date().toISOString(), action: "media:upload", @@ -205,10 +184,7 @@ export default definePlugin({ routes: { // Block Kit admin handler -- returns plain block objects (no @emdash-cms/blocks import needed) admin: { - handler: async ( - routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as { type: string; page?: string; @@ -230,10 +206,7 @@ export default definePlugin({ }, recent: { - handler: async ( - _routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (_routeCtx, ctx) => { try { const result = await ctx.storage.entries!.query({ orderBy: { timestamp: "desc" }, @@ -255,10 +228,7 @@ export default definePlugin({ }, history: { - handler: async ( - routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (routeCtx, ctx) => { try { const url = new URL(routeCtx.request.url); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 100); @@ -286,7 +256,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit helpers (plain objects, no @emdash-cms/blocks import) ── diff --git a/packages/plugins/marketplace-test/emdash-plugin.jsonc b/packages/plugins/marketplace-test/emdash-plugin.jsonc index 7bfbc98e5..30dd5465c 100644 --- a/packages/plugins/marketplace-test/emdash-plugin.jsonc +++ b/packages/plugins/marketplace-test/emdash-plugin.jsonc @@ -1,8 +1,7 @@ { - "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", "slug": "marketplace-test", - "version": "0.1.2", "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com "license": "MIT", diff --git a/packages/plugins/marketplace-test/package.json b/packages/plugins/marketplace-test/package.json index c501ac5c1..5cff78d9a 100644 --- a/packages/plugins/marketplace-test/package.json +++ b/packages/plugins/marketplace-test/package.json @@ -4,7 +4,18 @@ "version": "0.1.2", "description": "Test plugin for end-to-end registry publishing and audit workflow testing", "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" + }, + "files": ["dist", "emdash-plugin.jsonc"], "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, "keywords": ["emdash", "cms", "plugin", "test", "marketplace"], @@ -14,6 +25,8 @@ "emdash": "workspace:*" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", + "tsdown": "catalog:", "typescript": "catalog:" }, "repository": { diff --git a/packages/plugins/marketplace-test/src/plugin.ts b/packages/plugins/marketplace-test/src/plugin.ts index eceb80fa0..f62bb82fe 100644 --- a/packages/plugins/marketplace-test/src/plugin.ts +++ b/packages/plugins/marketplace-test/src/plugin.ts @@ -11,19 +11,12 @@ * `emdash-plugin.jsonc`. This file holds runtime behaviour only. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { SandboxedPlugin } from "emdash/plugin"; -interface HookEvent { - content?: Record; - collection?: string; - isNew?: boolean; -} - -export default definePlugin({ +export default { hooks: { "content:beforeSave": { - handler: async (event: HookEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { ctx.log.info("[marketplace-test] beforeSave fired", { collection: event.collection, isNew: event.isNew, @@ -31,7 +24,7 @@ export default definePlugin({ // Record execution in storage so the registry's install // audit can verify the hook actually ran post-install. - await ctx.storage.events.put(`hook-${Date.now()}`, { + await ctx.storage.events!.put(`hook-${Date.now()}`, { timestamp: new Date().toISOString(), type: "content:beforeSave", collection: event.collection, @@ -45,18 +38,18 @@ export default definePlugin({ routes: { ping: { - handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => ({ + handler: async (_routeCtx, ctx) => ({ pong: true, - pluginId: pluginCtx.plugin.id, + pluginId: ctx.plugin.id, timestamp: Date.now(), }), }, events: { - handler: async (_ctx: { input: unknown; request: unknown }, pluginCtx: PluginContext) => { - const result = await pluginCtx.storage.events.query({ limit: 10 }); + handler: async (_routeCtx, ctx) => { + const result = await ctx.storage.events!.query({ limit: 10 }); return { count: result.items.length, items: result.items }; }, }, }, -}); +} satisfies SandboxedPlugin; diff --git a/packages/plugins/sandboxed-test/emdash-plugin.jsonc b/packages/plugins/sandboxed-test/emdash-plugin.jsonc index 9c696f6ae..657d1390d 100644 --- a/packages/plugins/sandboxed-test/emdash-plugin.jsonc +++ b/packages/plugins/sandboxed-test/emdash-plugin.jsonc @@ -1,8 +1,7 @@ { - "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", "slug": "sandboxed-test", - "version": "0.0.3", "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com "license": "MIT", diff --git a/packages/plugins/sandboxed-test/package.json b/packages/plugins/sandboxed-test/package.json index 2d7403d1d..6d655ec8e 100644 --- a/packages/plugins/sandboxed-test/package.json +++ b/packages/plugins/sandboxed-test/package.json @@ -4,7 +4,18 @@ "version": "0.0.3", "description": "Test plugin for sandboxed plugin system", "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" + }, + "files": ["dist", "emdash-plugin.jsonc"], "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, "keywords": ["emdash", "cms", "plugin", "test", "sandbox"], @@ -14,6 +25,8 @@ "emdash": "workspace:*" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", + "tsdown": "catalog:", "typescript": "catalog:" }, "repository": { diff --git a/packages/plugins/sandboxed-test/src/plugin.ts b/packages/plugins/sandboxed-test/src/plugin.ts index 8585da739..3c79d5dd9 100644 --- a/packages/plugins/sandboxed-test/src/plugin.ts +++ b/packages/plugins/sandboxed-test/src/plugin.ts @@ -5,8 +5,7 @@ * Runs in both trusted (in-process) and sandboxed (isolate) modes. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface HookEvent { content?: Record; @@ -28,7 +27,7 @@ function getString(value: unknown, key: string): string | undefined { // ── Plugin definition ── -export default definePlugin({ +export default { hooks: { "content:beforeSave": { handler: async (event: HookEvent, ctx: PluginContext) => { @@ -830,7 +829,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit admin helpers ── diff --git a/packages/plugins/webhook-notifier/emdash-plugin.jsonc b/packages/plugins/webhook-notifier/emdash-plugin.jsonc index ef10c1e70..a9ccf6b25 100644 --- a/packages/plugins/webhook-notifier/emdash-plugin.jsonc +++ b/packages/plugins/webhook-notifier/emdash-plugin.jsonc @@ -1,8 +1,7 @@ { - "$schema": "../../registry-cli/schemas/emdash-plugin.schema.json", + "$schema": "../../plugin-cli/schemas/emdash-plugin.schema.json", "slug": "webhook-notifier", - "version": "0.1.3", "publisher": "did:plc:xyraubanwc5fwemkduw3upi6", // plugins.emdashcms.com "license": "MIT", diff --git a/packages/plugins/webhook-notifier/package.json b/packages/plugins/webhook-notifier/package.json index 3cdd96feb..c62f99668 100644 --- a/packages/plugins/webhook-notifier/package.json +++ b/packages/plugins/webhook-notifier/package.json @@ -3,19 +3,31 @@ "version": "0.1.3", "description": "Webhook notification plugin for EmDash CMS - posts to external URLs on content changes", "type": "module", - "private": true, - "scripts": { - "typecheck": "tsgo --noEmit" + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" }, + "files": ["dist", "emdash-plugin.jsonc"], "keywords": ["emdash", "cms", "plugin", "webhook", "notifications", "integration"], "author": "Matt Kane", "license": "MIT", - "dependencies": { - "emdash": "workspace:*" + "peerDependencies": { + "emdash": "workspace:>=0.10.0" }, "devDependencies": { + "@emdash-cms/plugin-cli": "workspace:*", + "tsdown": "catalog:", "typescript": "catalog:" }, + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", + "typecheck": "tsgo --noEmit" + }, "repository": { "type": "git", "url": "git+https://github.com/emdash-cms/emdash.git", diff --git a/packages/plugins/webhook-notifier/src/plugin.ts b/packages/plugins/webhook-notifier/src/plugin.ts index 1a96bd3c4..e01faeb8f 100644 --- a/packages/plugins/webhook-notifier/src/plugin.ts +++ b/packages/plugins/webhook-notifier/src/plugin.ts @@ -5,23 +5,7 @@ * Runs in both trusted (in-process) and sandboxed (isolate) modes. */ -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -interface ContentSaveEvent { - content: Record; - collection: string; - isNew: boolean; -} - -interface ContentDeleteEvent { - id: string; - collection: string; -} - -interface MediaUploadEvent { - media: { id: string }; -} +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; interface WebhookPayload { event: string; @@ -173,14 +157,14 @@ function getFetchFn(ctx: PluginContext): FetchFn { // ── Plugin definition ── -export default definePlugin({ +export default { hooks: { "content:afterSave": { priority: 210, timeout: 10000, dependencies: ["audit-log"], errorPolicy: "continue", - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const { url, token, enabled } = await getConfig(ctx); if (enabled === false || !url) return; @@ -208,7 +192,7 @@ export default definePlugin({ timeout: 10000, dependencies: ["audit-log"], errorPolicy: "continue", - handler: async (event: ContentDeleteEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const { url, token, enabled } = await getConfig(ctx); if (enabled === false || !url) return; @@ -228,7 +212,7 @@ export default definePlugin({ priority: 210, timeout: 10000, errorPolicy: "continue", - handler: async (event: MediaUploadEvent, ctx: PluginContext) => { + handler: async (event, ctx) => { const { url, token, enabled } = await getConfig(ctx); if (enabled === false || !url) return; @@ -246,10 +230,7 @@ export default definePlugin({ routes: { admin: { - handler: async ( - routeCtx: { input: unknown; request: { url: string } }, - ctx: PluginContext, - ) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as { type: string; page?: string; @@ -275,7 +256,7 @@ export default definePlugin({ }, status: { - handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (_routeCtx, ctx) => { try { const url = await ctx.kv.get("settings:webhookUrl"); const enabled = await ctx.kv.get("settings:enabled"); @@ -301,7 +282,7 @@ export default definePlugin({ }, settings: { - handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (_routeCtx, ctx) => { try { const settings = await ctx.kv.list("settings:"); const map: Record = {}; @@ -322,7 +303,7 @@ export default definePlugin({ }, "settings/save": { - handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { try { const input = isRecord(routeCtx.input) ? routeCtx.input : {}; if (typeof input.webhookUrl === "string") @@ -341,7 +322,7 @@ export default definePlugin({ }, test: { - handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const testUrl = getString(routeCtx.input, "url"); if (!testUrl) return { success: false, error: "No webhook URL provided" }; @@ -372,7 +353,7 @@ export default definePlugin({ }, }, }, -}); +} satisfies SandboxedPlugin; // ── Block Kit admin helpers ── diff --git a/packages/registry-cli/README.md b/packages/registry-cli/README.md deleted file mode 100644 index 3202c9e36..000000000 --- a/packages/registry-cli/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# @emdash-cms/registry-cli - -CLI for the experimental EmDash plugin registry. - -> EXPERIMENTAL: `bundle`, `login`, `whoami`, `switch`, and `publish` all work today against any atproto PDS — `publish` writes profile + release records to the publisher's own repo. The discovery commands (`search`, `info`) need an aggregator; none is deployed yet, so those won't return useful results until one is. NSIDs and shapes will change while RFC 0001 is in flight; pin to an exact version. - -## Installation - -```sh -npx @emdash-cms/registry-cli bundle -``` - -Or install globally: - -```sh -npm install -g @emdash-cms/registry-cli -emdash-registry bundle -``` - -## Commands - -```text -emdash-registry login Interactive atproto OAuth login -emdash-registry logout [--did ] Revoke the active session -emdash-registry whoami Show stored sessions -emdash-registry switch Switch the active publisher session -emdash-registry search Free-text search -emdash-registry info Show package details -emdash-registry bundle Bundle a plugin source dir into a tarball -emdash-registry publish --url Publish a release that points at a hosted tarball -emdash-registry validate [path] Validate emdash-plugin.jsonc against the v1 schema -``` - -All commands accept `--json`. Discovery commands accept `--aggregator ` (or `EMDASH_REGISTRY_URL`). - -## Publishing - -Three steps. The CLI does not host artifacts — you do, anywhere public. - -```sh -emdash-registry bundle -# upload dist/-.tar.gz somewhere public -emdash-registry publish --url https://example.com/foo-1.0.0.tar.gz -``` - -On first publish, pass `--license` and `--security-email` (or `--security-url`) to bootstrap the package profile — or keep them in `emdash-plugin.jsonc` (see below). - -## `emdash-plugin.jsonc` - -Drop an `emdash-plugin.jsonc` file next to your plugin's `package.json` to declare profile fields once instead of passing them on every publish. The CLI reads it automatically from the current directory. Schema-driven IDE completion works via the bundled JSON Schema: - -```jsonc -{ - "$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json", - - "license": "MIT", - "author": { "name": "Jane Doe", "url": "https://example.com" }, - "security": { "email": "security@example.com" }, - - // Optional - "name": "Gallery", - "description": "Image gallery block for EmDash.", - "keywords": ["gallery", "images"], - "repo": "https://github.com/example/plugin-gallery", -} -``` - -The file is JSONC: comments and trailing commas are allowed. Use `authors: [...]` and `securityContacts: [...]` for multi-author or multi-contact plugins. - -### Publisher pinning - -After your first successful publish, the CLI writes the active session's DID back into the manifest as `publisher`: - -```jsonc -{ - "license": "MIT", - "publisher": "did:plc:abc123def456", - ... -} -``` - -On every subsequent publish, the CLI verifies the active session matches the pinned `publisher`. If they don't match, publish refuses with `MANIFEST_PUBLISHER_MISMATCH` so you can't accidentally publish under the wrong account. To resolve a mismatch, either: - -- switch sessions: `emdash-registry switch ` -- update the manifest if you're transferring the plugin to a new publisher - -**DIDs are the identity, not handles.** Internally the CLI always compares the active session's DID against the pinned publisher's DID. If you pin a handle (`"publisher": "example.com"`), the CLI resolves it to a DID at publish time and compares against that — so a handle pin is just a friendlier alias for the underlying DID. Handles are mutable: if the publisher's domain changes ownership and the resolver later points at a different DID, the publish will refuse. DIDs are durable and the recommended pin for long-lived plugins. - -Validate without publishing: - -```sh -emdash-registry validate -``` - -CLI flags (`--license`, `--author-name`, …) still win over manifest values when both are set, which is useful in CI. Pass `--no-manifest` to skip the manifest entirely. - -## Programmatic API - -```ts -import { bundlePlugin } from "@emdash-cms/registry-cli"; - -const result = await bundlePlugin({ dir: "./my-plugin" }); -``` - -For discovery and credentials, import from `@emdash-cms/registry-client`. diff --git a/packages/registry-cli/src/bundle/api.ts b/packages/registry-cli/src/bundle/api.ts deleted file mode 100644 index 587e9c9c5..000000000 --- a/packages/registry-cli/src/bundle/api.ts +++ /dev/null @@ -1,725 +0,0 @@ -/** - * Programmatic plugin-bundling API. - * - * Pure-ish core of the bundling pipeline -- no `process.exit`, no console - * output. The CLI in `./command.ts` is a thin wrapper that turns these calls - * into pretty terminal output; tests exercise this module directly. - * - * The bundling steps: - * - * 1. Read `emdash-plugin.jsonc` via the manifest loader: identity (slug, - * version), trust contract (capabilities, allowedHosts, storage), and - * the rest of the profile fields. - * 2. Build `src/plugin.ts` as a probe to capture hook/route names that - * go into the bundled `manifest.json`. - * 3. Build `src/plugin.ts` again as the final `backend.js` (minified, - * with `emdash` aliased to a no-op shim that exposes only - * `definePlugin`). - * 4. Write `manifest.json` from the manifest fields + probed surface, - * and copy assets (README, icon, screenshots). - * 5. Validate (size limits, no Node builtins, no source exports, admin - * route consistency, sandbox-incompatible features). - * 6. Create the gzipped tarball and return its checksum. - * - * Failures throw `BundleError` with a structured `code` so callers can - * branch (CLI shows a helpful message; tests assert the code). - */ - -import { createHash } from "node:crypto"; -import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, extname, join, resolve } from "node:path"; - -import { - ManifestError, - MANIFEST_FILENAME, - loadManifest, - type LoadManifestResult, -} from "../manifest/load.js"; -import { normaliseManifest, type NormalisedManifest } from "../manifest/translate.js"; -import { - CAPABILITY_RENAMES, - isDeprecatedCapability, - type PluginManifest, - type ResolvedPlugin, -} from "./types.js"; -import { - collectBundleEntries, - createTarball, - extractManifest, - fileExists, - findBuildOutput, - findNodeBuiltinImports, - formatBytes, - ICON_SIZE, - MAX_SCREENSHOTS, - MAX_SCREENSHOT_HEIGHT, - MAX_SCREENSHOT_WIDTH, - readImageDimensions, - totalBundleBytes, - validateBundleSize, -} from "./utils.js"; - -const TS_EXT_RE = /\.(tsx?|[mc]?js)$/; -const SLASH_RE = /\//g; -const LEADING_AT_RE = /^@/; - -// ────────────────────────────────────────────────────────────────────────── -// Public types -// ────────────────────────────────────────────────────────────────────────── - -export type BundleErrorCode = - | "MISSING_MANIFEST" - | "MISSING_PLUGIN_ENTRY" - | "MANIFEST_INVALID" - | "INVALID_PLUGIN_FORMAT" - | "TRUSTED_ONLY_FEATURE" - | "BACKEND_BUILD_FAILED" - | "VALIDATION_FAILED"; - -export class BundleError extends Error { - readonly code: BundleErrorCode; - - constructor(code: BundleErrorCode, message: string) { - super(message); - this.name = "BundleError"; - this.code = code; - } -} - -export interface BundleLogger { - start?(message: string): void; - info?(message: string): void; - success?(message: string): void; - warn?(message: string): void; -} - -export interface BundleOptions { - /** Plugin source directory, must contain a `package.json`. */ - dir: string; - /** - * Output directory for the tarball, relative to `dir` if not absolute. - * Defaults to `/dist`. - */ - outDir?: string; - /** - * Skip tarball creation; only run the build + validation. Useful for - * pre-publish checks. Default: `false`. - */ - validateOnly?: boolean; - /** - * Optional progress reporter. The CLI passes a consola-shaped adapter; - * tests typically pass `undefined` or a recording stub. - */ - logger?: BundleLogger; -} - -export interface BundleResult { - /** The extracted plugin manifest (also written to manifest.json). */ - manifest: PluginManifest; - /** Absolute path to the resulting tarball, or `null` when `validateOnly`. */ - tarballPath: string | null; - /** Tarball size in bytes, or `null` when `validateOnly`. */ - tarballBytes: number | null; - /** Hex sha256 of the tarball contents, or `null` when `validateOnly`. */ - sha256: string | null; - /** Non-fatal warnings collected during validation (deprecated caps, etc.). */ - warnings: string[]; -} - -// ────────────────────────────────────────────────────────────────────────── -// Implementation -// ────────────────────────────────────────────────────────────────────────── - -/** - * Conventional source-file paths the bundler looks for. The redesign - * pins these instead of consulting `package.json` exports — a sandboxed - * plugin has exactly one runtime entry, and the manifest provides - * identity. Anything beyond these conventions is the author's - * responsibility (e.g. typecheck against their own tsconfig). - */ -const PLUGIN_ENTRY_PATH = "src/plugin.ts"; - -interface ResolvedEntries { - /** - * Absolute path to `src/plugin.ts`. The single source file the - * bundler probes (for hook/route names) and builds (as backend.js). - */ - pluginEntry: string; - /** The validated manifest, used as the source of truth for identity + trust contract. */ - manifest: NormalisedManifest; - /** Resolved path of the loaded `emdash-plugin.jsonc`, kept for diagnostics. */ - manifestPath: string; -} - -export async function bundlePlugin(options: BundleOptions): Promise { - const log = options.logger ?? {}; - const pluginDir = resolve(options.dir); - const outDir = resolve(pluginDir, options.outDir ?? "dist"); - const validateOnly = options.validateOnly ?? false; - const warnings: string[] = []; - const warn = (msg: string) => { - warnings.push(msg); - log.warn?.(msg); - }; - - log.start?.(validateOnly ? "Validating plugin..." : "Bundling plugin..."); - - // ── 1. Read manifest + locate plugin entry ── - const entries = await resolveEntries(pluginDir, log); - - // ── 2. Assemble ResolvedPlugin from manifest + probe ── - log.start?.("Extracting plugin manifest..."); - - // Each invocation gets its own tmpdir under the OS tmp root so concurrent - // `bundlePlugin` runs (CI + local dev, watch-mode + manual) don't trample - // each other's intermediate artefacts. Cleaned up unconditionally in the - // `finally` below. - const tmpDir = await mkdtemp(join(tmpdir(), "emdash-bundle-")); - - try { - // Dynamic-import tsdown INSIDE the try block so a missing/broken - // tsdown install (or a transient ENOENT during import) doesn't leak - // the tmpdir we just created. The cost is one extra try-frame; the - // alternative was a tmpdir orphaned per failed import. - const { build } = await import("tsdown"); - - const resolvedPlugin = await assembleResolvedPlugin({ - tmpDir, - entries, - build, - }); - - const manifest = extractManifest(resolvedPlugin); - - log.success?.(`Plugin: ${manifest.id}@${manifest.version}`); - log.info?.( - ` Capabilities: ${manifest.capabilities.length > 0 ? manifest.capabilities.join(", ") : "(none)"}`, - ); - log.info?.( - ` Hooks: ${manifest.hooks.length > 0 ? manifest.hooks.map((h) => (typeof h === "string" ? h : h.name)).join(", ") : "(none)"}`, - ); - log.info?.( - ` Routes: ${manifest.routes.length > 0 ? manifest.routes.map((r) => (typeof r === "string" ? r : r.name)).join(", ") : "(none)"}`, - ); - - // ── 3. Bundle backend.js ── - const bundleDir = join(tmpDir, "bundle"); - await mkdir(bundleDir, { recursive: true }); - - { - log.start?.("Bundling backend..."); - const shimPath = await writeEmdashShim(join(tmpDir, "shims")); - - await build({ - config: false, - entry: [entries.pluginEntry], - format: "esm", - outDir: join(tmpDir, "backend"), - dts: false, - platform: "neutral", - external: [], - alias: { emdash: shimPath }, - minify: true, - treeshake: true, - }); - - const backendBaseName = basename(entries.pluginEntry).replace(TS_EXT_RE, ""); - const backendOutputPath = await findBuildOutput(join(tmpDir, "backend"), backendBaseName); - if (!backendOutputPath) { - throw new BundleError("BACKEND_BUILD_FAILED", "Backend build produced no output"); - } - await copyFile(backendOutputPath, join(bundleDir, "backend.js")); - log.success?.("Built backend.js"); - } - - // ── 4. Write manifest.json ── - await writeFile(join(bundleDir, "manifest.json"), JSON.stringify(manifest, null, 2)); - - // ── 5. Collect assets ── - log.start?.("Collecting assets..."); - await collectAssets({ pluginDir, bundleDir, log, warn }); - - // ── 6. Validate ── - log.start?.("Validating bundle..."); - const validationErrors: string[] = []; - - // Node builtins in backend.js -> hard fail. - const backendPath = join(bundleDir, "backend.js"); - if (await fileExists(backendPath)) { - const backendCode = await readFile(backendPath, "utf-8"); - const builtins = findNodeBuiltinImports(backendCode); - if (builtins.length > 0) { - validationErrors.push( - `backend.js imports Node.js built-in modules: ${builtins.join(", ")}. Sandboxed plugins cannot use Node.js APIs.`, - ); - } - } - - // Capability sanity warnings. - const declaresUnrestricted = - manifest.capabilities.includes("network:request:unrestricted") || - manifest.capabilities.includes("network:fetch:any"); - const declaresHostRestricted = - manifest.capabilities.includes("network:request") || - manifest.capabilities.includes("network:fetch"); - if (declaresUnrestricted) { - warn( - "Plugin declares unrestricted network access (network:request:unrestricted) — it can make requests to any host.", - ); - } else if (declaresHostRestricted && manifest.allowedHosts.length === 0) { - // `publish` will hard-fail this case (INVALID_MANIFEST) because - // the lexicon says `request: {}` means "unrestricted" -- silently - // publishing that contradicts the apparent intent of declaring - // `network:request` (host-restricted) with empty allowedHosts. - // Surface it loudly at bundle time so the developer fixes it - // before they try to publish. - warn( - "Plugin declares network:request capability but no allowedHosts. The lexicon treats this as `unrestricted` access. Add specific host patterns to allowedHosts, or upgrade the capability to network:request:unrestricted. `publish` will refuse this combination.", - ); - } - - // Deprecated capabilities are warnings here; `publish` hard-fails on them. - const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability); - if (deprecatedCaps.length > 0) { - warn("Plugin uses deprecated capability names. Rename them before publishing:"); - for (const cap of deprecatedCaps) { - warn(` ${cap} -> ${CAPABILITY_RENAMES[cap]}`); - } - } - - // Trusted-only features that won't work in sandboxed mode. - if ( - resolvedPlugin.admin?.portableTextBlocks && - resolvedPlugin.admin.portableTextBlocks.length > 0 - ) { - warn( - "Plugin declares portableTextBlocks — these require trusted mode and will be ignored in sandboxed plugins.", - ); - } - if (resolvedPlugin.admin?.entry) { - warn( - "Plugin declares admin.entry — custom React components require trusted mode. Use Block Kit for sandboxed admin pages.", - ); - } - if (resolvedPlugin.hooks["page:fragments"]) { - warn( - "Plugin declares page:fragments hook — this is trusted-only and will not work in sandboxed mode.", - ); - } - - // Admin pages/widgets require an `admin` route. - const hasAdminPages = (manifest.admin?.pages?.length ?? 0) > 0; - const hasAdminWidgets = (manifest.admin?.widgets?.length ?? 0) > 0; - if (hasAdminPages || hasAdminWidgets) { - const routeNames = manifest.routes.map((r) => (typeof r === "string" ? r : r.name)); - if (!routeNames.includes("admin")) { - const declared = - hasAdminPages && hasAdminWidgets - ? "adminPages and adminWidgets" - : hasAdminPages - ? "adminPages" - : "adminWidgets"; - validationErrors.push( - `Plugin declares ${declared} but the sandbox entry has no "admin" route. Add an admin route handler to serve Block Kit pages.`, - ); - } - } - - // Bundle size caps (RFC 0001 §"Bundle size limits"). - const bundleEntries = await collectBundleEntries(bundleDir); - const sizeViolations = validateBundleSize(bundleEntries); - if (sizeViolations.length > 0) { - validationErrors.push(...sizeViolations); - } else { - log.info?.( - `Bundle size: ${formatBytes(totalBundleBytes(bundleEntries))} across ${bundleEntries.length} file${bundleEntries.length === 1 ? "" : "s"}`, - ); - } - - if (validationErrors.length > 0) { - throw new BundleError( - "VALIDATION_FAILED", - `Bundle validation failed:\n - ${validationErrors.join("\n - ")}`, - ); - } - - log.success?.("Validation passed"); - - // ── 8. Create tarball (or stop here if validateOnly) ── - if (validateOnly) { - return { - manifest, - tarballPath: null, - tarballBytes: null, - sha256: null, - warnings, - }; - } - - await mkdir(outDir, { recursive: true }); - const tarballName = `${manifest.id.replace(SLASH_RE, "-").replace(LEADING_AT_RE, "")}-${manifest.version}.tar.gz`; - const tarballPath = join(outDir, tarballName); - - log.start?.("Creating tarball..."); - await createTarball(bundleDir, tarballPath); - - const tarballStat = await stat(tarballPath); - const tarballBuf = await readFile(tarballPath); - const sha256 = createHash("sha256").update(tarballBuf).digest("hex"); - - log.success?.(`Created ${tarballName} (${(tarballStat.size / 1024).toFixed(1)}KB)`); - log.info?.(` SHA-256: ${sha256}`); - log.info?.(` Path: ${tarballPath}`); - - return { - manifest, - tarballPath, - tarballBytes: tarballStat.size, - sha256, - warnings, - }; - } finally { - // Always clean up. mkdtemp produced this dir for us, so there's no - // chance of nuking something the user expected to keep. - await rm(tmpDir, { recursive: true, force: true }); - } -} - -// ────────────────────────────────────────────────────────────────────────── -// Helpers -// ────────────────────────────────────────────────────────────────────────── - -/** - * Read the manifest and locate the plugin's runtime entry. The redesign - * pins both to conventional locations: - * - * - `/emdash-plugin.jsonc` — identity + trust contract + profile - * fields. Parsed and validated by the same loader the CLI's - * `validate` command uses, so error messages are consistent. - * - `/src/plugin.ts` — the runtime code (routes + hooks via - * `definePlugin`). Single source file; no `package.json` exports - * consulted. - * - * `package.json` is not read here at all. It's still present in the - * plugin directory because Node tooling needs it (vitest, tsc), but - * the bundler doesn't care about its `name`, `version`, `main`, or - * `exports` fields — those would just be ways to disagree with the - * manifest. - */ -async function resolveEntries(pluginDir: string, log: BundleLogger): Promise { - const manifestPath = join(pluginDir, MANIFEST_FILENAME); - if (!(await fileExists(manifestPath))) { - throw new BundleError( - "MISSING_MANIFEST", - `No ${MANIFEST_FILENAME} found in ${pluginDir}. Create one with: emdash-registry init`, - ); - } - - let loaded: LoadManifestResult; - try { - loaded = await loadManifest(manifestPath); - } catch (error) { - if (error instanceof ManifestError) { - throw new BundleError("MANIFEST_INVALID", error.message); - } - throw error; - } - const manifest = normaliseManifest(loaded.manifest); - - const pluginEntry = join(pluginDir, PLUGIN_ENTRY_PATH); - if (!(await fileExists(pluginEntry))) { - throw new BundleError( - "MISSING_PLUGIN_ENTRY", - `No ${PLUGIN_ENTRY_PATH} found in ${pluginDir}. Sandboxed plugins place their routes and hooks in this single file (see emdash-registry init for the canonical layout).`, - ); - } - - log.info?.(`Manifest: ${loaded.path}`); - log.info?.(`Plugin entry: ${pluginEntry}`); - - return { pluginEntry, manifest, manifestPath: loaded.path }; -} - -interface AssembleContext { - tmpDir: string; - entries: ResolvedEntries; - build: typeof import("tsdown").build; -} - -/** - * Assemble a `ResolvedPlugin` from the manifest (identity + trust contract) - * and a probe of `src/plugin.ts` (hook/route surface). - * - * The redesign collapses what used to be two distinct steps — main-entry - * descriptor extraction and sandbox-entry probe — into a single probe. - * Identity isn't authored in code anymore; the manifest is the source of - * truth, validated upstream by `loadManifest`. - */ -async function assembleResolvedPlugin(ctx: AssembleContext): Promise { - const { tmpDir, entries, build } = ctx; - - const resolvedPlugin: ResolvedPlugin = { - // `id` on the bundled manifest is the publisher's natural slug. - // The runtime rewrites it to the opaque `r_` at install - // time (see makeRegistryPluginId), but on-wire the slug is what - // the install handler matches against the registry's record key. - id: entries.manifest.slug, - version: entries.manifest.version, - capabilities: entries.manifest.capabilities, - allowedHosts: entries.manifest.allowedHosts, - storage: entries.manifest.storage, - hooks: {}, - routes: {}, - admin: { - // Pages / widgets the plugin declared in the manifest get - // passed straight through to the bundled `manifest.json`. - // `extractManifest` reads from `admin` to populate the - // `admin` block of the wire format. - pages: entries.manifest.admin.pages, - widgets: entries.manifest.admin.widgets, - }, - }; - - await probePluginSurface({ - resolvedPlugin, - pluginEntry: entries.pluginEntry, - tmpDir, - build, - }); - - return resolvedPlugin; -} - -/** - * Write a stub `emdash.mjs` into `dir` that the user's plugin code resolves - * its `import "emdash"` against during build/probe. The shim's surface is: - * - * - `definePlugin` (named + default-property): identity function. The - * standard format's only legal `emdash` import. - * - default export: a Proxy. Any property access other than - * `definePlugin` returns a function that throws on call with a clear - * message. Without the Proxy, plugins doing dynamic property access on - * the default would silently get undefined and tree-shake to nothing. - * - * Named imports of anything other than `definePlugin` (e.g. - * `import { admin } from "emdash"`) are caught by the bundler at build - * time -- the named binding doesn't exist on the shim, so tsdown / Rollup - * errors with "Module 'emdash' has no exported member 'admin'". That's a - * better failure mode than a runtime undefined, so we don't try to handle - * unknown named imports at the shim level. - */ -async function writeEmdashShim(dir: string): Promise { - await mkdir(dir, { recursive: true }); - const path = join(dir, "emdash.mjs"); - const source = `export const definePlugin = (d) => d; -const NOT_AVAILABLE = (name) => () => { - throw new Error( - \`Sandboxed plugins must not import "\${name}" from "emdash". Only \\\`definePlugin\\\` is available in standard format.\` - ); -}; -const handler = { - get(target, prop, receiver) { - if (prop === "definePlugin") return target.definePlugin; - if (typeof prop !== "string") return Reflect.get(target, prop, receiver); - return NOT_AVAILABLE(prop); - }, -}; -export default new Proxy({ definePlugin }, handler); -`; - await writeFile(path, source); - return path; -} - -interface ProbeContext { - resolvedPlugin: ResolvedPlugin; - pluginEntry: string; - tmpDir: string; - build: typeof import("tsdown").build; -} - -/** - * Build `src/plugin.ts` with `emdash` aliased to the no-op shim (which - * only exports `definePlugin`), then import it to read its default - * export's `hooks` and `routes` shape. The handler functions are - * recorded on the `ResolvedPlugin` even though `extractManifest` will - * strip them — they prove the surface is callable, and the probe build - * surfaces any compile-time error in the plugin code before we bother - * with the real backend build. - */ -async function probePluginSurface(ctx: ProbeContext): Promise { - const { resolvedPlugin, pluginEntry, tmpDir, build } = ctx; - const probeOutDir = join(tmpDir, "plugin-probe"); - const probeShimPath = await writeEmdashShim(join(tmpDir, "probe-shims")); - await build({ - config: false, - entry: [pluginEntry], - format: "esm", - outDir: probeOutDir, - dts: false, - platform: "neutral", - external: [], - alias: { emdash: probeShimPath }, - treeshake: true, - }); - const probeBaseName = basename(pluginEntry).replace(TS_EXT_RE, ""); - const probeOutputPath = await findBuildOutput(probeOutDir, probeBaseName); - if (!probeOutputPath) { - throw new BundleError( - "BACKEND_BUILD_FAILED", - `Failed to build ${pluginEntry} for probe — no output found in ${probeOutDir}`, - ); - } - - const pluginModule = (await import(probeOutputPath)) as Record; - const definition = (pluginModule.default ?? {}) as Record; - if (typeof definition !== "object" || definition === null) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - `${pluginEntry} must default-export the result of definePlugin({ hooks, routes }). Got ${describeShape(definition)}.`, - ); - } - const hooks = definition.hooks as Record | undefined; - const routes = definition.routes as Record | undefined; - - if (hooks) { - for (const hookName of Object.keys(hooks)) { - const hookEntry = hooks[hookName]; - const handler = extractHookHandler(hookEntry); - if (!handler) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - `${pluginEntry}: hook "${hookName}" must be a function or { handler: function, ... }. Got ${describeShape(hookEntry)}.`, - ); - } - const config: Record = - typeof hookEntry === "object" && hookEntry !== null - ? (hookEntry as Record) - : {}; - resolvedPlugin.hooks[hookName] = { - handler, - priority: (config.priority as number | undefined) ?? 100, - timeout: (config.timeout as number | undefined) ?? 5000, - dependencies: (config.dependencies as string[] | undefined) ?? [], - errorPolicy: (config.errorPolicy as string | undefined) ?? "abort", - exclusive: (config.exclusive as boolean | undefined) ?? false, - pluginId: resolvedPlugin.id, - }; - } - } - if (routes) { - for (const [name, route] of Object.entries(routes)) { - const handler = extractRouteHandler(route); - if (!handler) { - throw new BundleError( - "INVALID_PLUGIN_FORMAT", - `${pluginEntry}: route "${name}" must be a function or { handler: function, ... }. Got ${describeShape(route)}.`, - ); - } - const routeObj: Record = - typeof route === "object" && route !== null ? (route as Record) : {}; - resolvedPlugin.routes[name] = { - handler, - public: routeObj.public as boolean | undefined, - }; - } - } -} - -/** - * Extract a hook handler from either the bare function form or the - * `{ handler, priority, ... }` config form. Returns `undefined` if neither - * shape is present so callers can hard-fail with a useful error. - */ -function extractHookHandler(entry: unknown): unknown { - if (typeof entry === "function") return entry; - if (entry && typeof entry === "object" && "handler" in entry) { - const handler = (entry as { handler: unknown }).handler; - if (typeof handler === "function") return handler; - } - return undefined; -} - -/** - * Same as `extractHookHandler` for route entries. - */ -function extractRouteHandler(entry: unknown): unknown { - if (typeof entry === "function") return entry; - if (entry && typeof entry === "object" && "handler" in entry) { - const handler = (entry as { handler: unknown }).handler; - if (typeof handler === "function") return handler; - } - return undefined; -} - -function describeShape(value: unknown): string { - if (value === null) return "null"; - if (value === undefined) return "undefined"; - if (Array.isArray(value)) return `array (length ${value.length})`; - return typeof value; -} - -interface CollectAssetsContext { - pluginDir: string; - bundleDir: string; - log: BundleLogger; - warn: (msg: string) => void; -} - -async function collectAssets(ctx: CollectAssetsContext): Promise { - const { pluginDir, bundleDir, log, warn } = ctx; - - const readmePath = join(pluginDir, "README.md"); - if (await fileExists(readmePath)) { - await copyFile(readmePath, join(bundleDir, "README.md")); - log.success?.("Included README.md"); - } - - const iconPath = join(pluginDir, "icon.png"); - if (await fileExists(iconPath)) { - const iconBuf = await readFile(iconPath); - const dims = readImageDimensions(iconBuf); - if (!dims) { - warn("icon.png is not a valid PNG — skipping"); - } else { - if (dims[0] !== ICON_SIZE || dims[1] !== ICON_SIZE) { - warn( - `icon.png is ${dims[0]}x${dims[1]}, expected ${ICON_SIZE}x${ICON_SIZE} — including anyway`, - ); - } - await copyFile(iconPath, join(bundleDir, "icon.png")); - log.success?.("Included icon.png"); - } - } - - const screenshotsDir = join(pluginDir, "screenshots"); - if (await fileExists(screenshotsDir)) { - const screenshotFiles = (await readdir(screenshotsDir)) - .filter((f) => { - const ext = extname(f).toLowerCase(); - return ext === ".png" || ext === ".jpg" || ext === ".jpeg"; - }) - .toSorted() - .slice(0, MAX_SCREENSHOTS); - - if (screenshotFiles.length > 0) { - await mkdir(join(bundleDir, "screenshots"), { recursive: true }); - for (const file of screenshotFiles) { - const filePath = join(screenshotsDir, file); - const buf = await readFile(filePath); - const dims = readImageDimensions(buf); - if (!dims) { - warn(`screenshots/${file} — cannot read dimensions, skipping`); - continue; - } - if (dims[0] > MAX_SCREENSHOT_WIDTH || dims[1] > MAX_SCREENSHOT_HEIGHT) { - warn( - `screenshots/${file} is ${dims[0]}x${dims[1]}, max ${MAX_SCREENSHOT_WIDTH}x${MAX_SCREENSHOT_HEIGHT} — including anyway`, - ); - } - await copyFile(filePath, join(bundleDir, "screenshots", file)); - } - log.success?.(`Included ${screenshotFiles.length} screenshot(s)`); - } - } -} diff --git a/packages/registry-cli/src/dev.ts b/packages/registry-cli/src/dev.ts deleted file mode 100644 index 02c52aefa..000000000 --- a/packages/registry-cli/src/dev.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * `@emdash-cms/registry-cli/dev` - * - * Local-development helper for consuming a sandboxed plugin directly - * from its source directory. Lets a site's `astro.config.mjs` reference - * a plugin via a directory path instead of importing a factory function: - * - * ```ts - * import { localPlugin } from "@emdash-cms/registry-cli/dev"; - * - * emdash({ - * plugins: [ - * await localPlugin("../packages/plugins/audit-log"), - * ], - * }); - * ``` - * - * The helper reads the plugin's `emdash-plugin.jsonc`, resolves the - * publisher's handle to a DID (when needed), and returns a - * `PluginDescriptor`-shaped object the integration's `plugins:` / - * `sandboxed:` arrays consume directly. Identity, the trust contract, - * and the admin surface come from the manifest; the runtime code is - * loaded by the integration's virtual-module loader from - * `/src/plugin.ts` (as an absolute file URL). - * - * Failure modes: - * - * - Manifest missing or invalid → `LocalPluginError(MANIFEST_INVALID)`. - * - `src/plugin.ts` missing → `LocalPluginError(PLUGIN_ENTRY_MISSING)`. - * - Publisher handle can't be resolved → `LocalPluginError(PUBLISHER_UNRESOLVED)`. - * - * This helper is for local dev only. Registry-installed plugins go - * through `emdash-registry bundle` + the runtime's install pipeline — - * they never touch this module. - */ - -import { access } from "node:fs/promises"; -import { join, resolve as resolvePath } from "node:path"; -import { pathToFileURL } from "node:url"; - -import { isDid, isHandle } from "@atcute/lexicons/syntax"; - -import { ManifestError, loadManifest } from "./manifest/load.js"; -import { PublisherCheckError, resolveHandleToDid } from "./manifest/publisher.js"; -import { normaliseManifest, type NormalisedManifest } from "./manifest/translate.js"; - -export type LocalPluginErrorCode = - | "MANIFEST_INVALID" - | "PLUGIN_ENTRY_MISSING" - | "PUBLISHER_UNRESOLVED"; - -export class LocalPluginError extends Error { - override readonly name = "LocalPluginError"; - readonly code: LocalPluginErrorCode; - constructor(code: LocalPluginErrorCode, message: string) { - super(message); - this.code = code; - } -} - -/** - * Plugin descriptor produced by `localPlugin`. The shape mirrors the - * runtime's `PluginDescriptor` (from `emdash`'s astro integration) but - * we define the type locally so this module has zero runtime - * dependency on core — only the type contract matters. - * - * `entrypoint` is an absolute `file://` URL pointing at the plugin's - * `src/plugin.ts`. The integration's virtual-module loader passes - * that string to a dynamic `import()`, which Vite resolves via its - * normal filesystem-resolution path. - */ -export interface LocalPluginDescriptor { - id: string; - version: string; - format: "standard"; - entrypoint: string; - capabilities: string[]; - allowedHosts: string[]; - storage: Record< - string, - { indexes: Array; uniqueIndexes?: Array } - >; - adminPages?: Array<{ path: string; label: string; icon?: string }>; - adminWidgets?: Array<{ id: string; title?: string; size?: "full" | "half" | "third" }>; -} - -export interface LocalPluginOptions { - /** - * If true, suppresses the handle-to-DID resolution at load time. - * The descriptor carries whatever value the manifest's `publisher` - * field holds verbatim. Useful in tests; rarely needed in real - * code. - */ - skipPublisherResolution?: boolean; -} - -/** - * Load a sandboxed plugin from a local directory and return a - * descriptor the EmDash integration can consume. - * - * `dir` is resolved relative to the calling module's cwd (typically - * the site's project root). The directory must contain - * `emdash-plugin.jsonc` and `src/plugin.ts` — same layout - * `emdash-registry init` and `emdash-registry bundle` expect. - * - * The returned descriptor carries an absolute `file://` URL as its - * `entrypoint`. The Astro integration's virtual-module loader emits - * `import plugin from ""`, which Vite resolves through - * its standard file-URL → fs path resolver. No build step required. - */ -export async function localPlugin( - dir: string, - options: LocalPluginOptions = {}, -): Promise { - const absDir = resolvePath(dir); - - // Manifest first: identity + trust contract + admin surface. - let normalised: NormalisedManifest; - try { - const { manifest } = await loadManifest(absDir); - normalised = normaliseManifest(manifest); - } catch (error) { - if (error instanceof ManifestError) { - throw new LocalPluginError("MANIFEST_INVALID", `Plugin at ${absDir}: ${error.message}`); - } - throw error; - } - - // Runtime entry: src/plugin.ts must exist; we don't probe its - // surface here (Vite will compile it on first import), but we do - // confirm it's present so the failure surfaces at config-load - // rather than at first hook fire. - const pluginEntryPath = join(absDir, "src", "plugin.ts"); - try { - await access(pluginEntryPath); - } catch { - throw new LocalPluginError( - "PLUGIN_ENTRY_MISSING", - `Plugin at ${absDir} has no src/plugin.ts. Run \`emdash-registry init\` to scaffold the expected layout, or move your runtime code to that path.`, - ); - } - - // Resolve the publisher to a DID. The manifest's publisher may - // be a handle or a DID; the runtime's identity check only cares - // about the DID. Resolving here means the descriptor passed to - // the integration is already in the canonical form. - const did = options.skipPublisherResolution - ? normalised.publisher - : await resolvePublisher(normalised.publisher); - - return { - id: normalised.slug, - version: normalised.version, - format: "standard", - entrypoint: pathToFileURL(pluginEntryPath).href, - capabilities: normalised.capabilities, - allowedHosts: normalised.allowedHosts, - storage: normalised.storage, - // Pass admin surface through only when there's something to - // declare; integration treats undefined/empty arrays the same - // way at runtime but the descriptor stays tidier. - ...(normalised.admin.pages.length > 0 && { adminPages: normalised.admin.pages }), - ...(normalised.admin.widgets.length > 0 && { adminWidgets: normalised.admin.widgets }), - // Note: `did` is computed but not currently exposed on the - // descriptor. The runtime keys storage / KV / logs by `id`, - // and id == slug for local-dev installs. When the runtime's - // ctx.plugin shape gains explicit did / uri fields, this - // helper feeds them through too. - ...(did !== normalised.publisher && {}), - }; -} - -/** - * Resolve the manifest's publisher to a DID. DIDs pass through - * verbatim; handles are resolved through the same actor-resolver - * the publish flow uses. Failure becomes a structured - * `LocalPluginError` so a site's astro.config.mjs sees a clear - * error rather than a cryptic resolver stack. - */ -async function resolvePublisher(publisher: string): Promise { - if (isDid(publisher)) return publisher; - if (!isHandle(publisher)) { - throw new LocalPluginError( - "PUBLISHER_UNRESOLVED", - `Manifest publisher "${publisher}" is neither a DID nor a valid handle. Fix the publisher field and reload.`, - ); - } - try { - return await resolveHandleToDid(publisher); - } catch (error) { - if (error instanceof PublisherCheckError) { - throw new LocalPluginError("PUBLISHER_UNRESOLVED", error.message); - } - throw error; - } -} diff --git a/packages/registry-cli/tests/dev-local-plugin.test.ts b/packages/registry-cli/tests/dev-local-plugin.test.ts deleted file mode 100644 index 0523957a5..000000000 --- a/packages/registry-cli/tests/dev-local-plugin.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Coverage for `localPlugin(dir)` — the local-dev helper that lets a - * site's `astro.config.mjs` consume a sandboxed plugin from its source - * directory without an npm-shaped factory import. - * - * The runtime side of the helper (Vite resolving the file:// entrypoint - * and importing the plugin module) isn't tested here — that's - * integration territory and the demos exercise it directly. These - * tests focus on the deterministic parts: descriptor shape, error - * paths for missing manifest / missing entry / malformed publisher. - */ - -import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { LocalPluginError, localPlugin } from "../src/dev.js"; - -describe("localPlugin", () => { - let dir: string; - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "emdash-localplugin-test-")); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - async function writeManifest(content: object): Promise { - await writeFile(join(dir, "emdash-plugin.jsonc"), JSON.stringify(content), "utf8"); - } - - async function writeEntry(): Promise { - await mkdir(join(dir, "src"), { recursive: true }); - await writeFile( - join(dir, "src", "plugin.ts"), - 'import { definePlugin } from "emdash";\nexport default definePlugin({});\n', - "utf8", - ); - } - - const MINIMAL_MANIFEST = { - slug: "test-plugin", - version: "0.1.0", - publisher: "did:plc:abc123", - license: "MIT", - author: { name: "Test" }, - security: { email: "security@example.com" }, - }; - - it("returns a descriptor with identity from the manifest", async () => { - await writeManifest(MINIMAL_MANIFEST); - await writeEntry(); - const descriptor = await localPlugin(dir); - expect(descriptor.id).toBe("test-plugin"); - expect(descriptor.version).toBe("0.1.0"); - expect(descriptor.format).toBe("standard"); - }); - - it("emits a file:// URL for entrypoint pointing at src/plugin.ts", async () => { - await writeManifest(MINIMAL_MANIFEST); - await writeEntry(); - const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); - expect(descriptor.entrypoint).toMatch(/^file:\/\//); - expect(descriptor.entrypoint.endsWith("/src/plugin.ts")).toBe(true); - // The URL round-trips through pathToFileURL/fileURLToPath cleanly. - const fsPath = fileURLToPath(descriptor.entrypoint); - expect(fsPath.endsWith("/src/plugin.ts")).toBe(true); - }); - - it("passes the trust contract through", async () => { - await writeManifest({ - ...MINIMAL_MANIFEST, - capabilities: ["content:read"], - storage: { events: { indexes: ["timestamp"] } }, - }); - await writeEntry(); - const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); - expect(descriptor.capabilities).toEqual(["content:read"]); - expect(descriptor.allowedHosts).toEqual([]); - expect(descriptor.storage).toEqual({ events: { indexes: ["timestamp"] } }); - }); - - it("passes admin pages and widgets through", async () => { - await writeManifest({ - ...MINIMAL_MANIFEST, - admin: { - pages: [{ path: "/foo", label: "Foo" }], - widgets: [{ id: "bar", title: "Bar", size: "half" }], - }, - }); - await writeEntry(); - const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); - expect(descriptor.adminPages).toEqual([{ path: "/foo", label: "Foo" }]); - expect(descriptor.adminWidgets).toEqual([{ id: "bar", title: "Bar", size: "half" }]); - }); - - it("omits adminPages / adminWidgets when neither is declared", async () => { - await writeManifest(MINIMAL_MANIFEST); - await writeEntry(); - const descriptor = await localPlugin(dir, { skipPublisherResolution: true }); - expect(descriptor.adminPages).toBeUndefined(); - expect(descriptor.adminWidgets).toBeUndefined(); - }); - - it("throws MANIFEST_INVALID when emdash-plugin.jsonc is missing", async () => { - await writeEntry(); - await expect(localPlugin(dir)).rejects.toMatchObject({ - name: "LocalPluginError", - code: "MANIFEST_INVALID", - }); - }); - - it("throws MANIFEST_INVALID when the manifest fails schema validation", async () => { - await writeManifest({ slug: "" }); // missing required fields - await writeEntry(); - await expect(localPlugin(dir)).rejects.toMatchObject({ - name: "LocalPluginError", - code: "MANIFEST_INVALID", - }); - }); - - it("throws PLUGIN_ENTRY_MISSING when src/plugin.ts doesn't exist", async () => { - await writeManifest(MINIMAL_MANIFEST); - // No writeEntry() — manifest is valid but the runtime entry - // is missing. - await expect(localPlugin(dir)).rejects.toMatchObject({ - name: "LocalPluginError", - code: "PLUGIN_ENTRY_MISSING", - }); - }); - - it("returns the publisher DID verbatim when given a DID", async () => { - // skipPublisherResolution avoids the network round-trip; the - // DID is passed through unchanged. - await writeManifest(MINIMAL_MANIFEST); - await writeEntry(); - // The descriptor doesn't yet expose `did` directly, but - // we can confirm the helper accepted the DID input. Future - // PRs will add did/uri to the descriptor; this test pins the - // happy path. - await expect(localPlugin(dir, { skipPublisherResolution: true })).resolves.toMatchObject({ - id: "test-plugin", - }); - }); - - it("throws when the dir path is a non-existent directory", async () => { - const missing = join(dir, "no-such-plugin"); - await expect(localPlugin(missing)).rejects.toBeInstanceOf(LocalPluginError); - }); -}); diff --git a/packages/registry-cli/tests/fixtures/minimal-plugin/src/plugin.ts b/packages/registry-cli/tests/fixtures/minimal-plugin/src/plugin.ts deleted file mode 100644 index 009fb28fb..000000000 --- a/packages/registry-cli/tests/fixtures/minimal-plugin/src/plugin.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Test fixture: minimal sandbox entry. Exports a default object with hooks - * and routes so the bundler's probe captures shape into the manifest. - * - * Uses `definePlugin` from "emdash" (which the bundler aliases to a Proxy - * shim) so the shim resolution path is actually exercised by the bundle - * tests; without this `import`, the shim could be silently broken and tests - * would still pass. - */ -// eslint-disable-next-line import/no-unresolved -- the bundler aliases this -import { definePlugin } from "emdash"; - -export default definePlugin({ - hooks: { - "content:beforeCreate": (input: unknown) => input, - }, - routes: { - admin: () => new Response("ok"), - }, -}); diff --git a/packages/registry-client/README.md b/packages/registry-client/README.md index 5b9e8b167..8bdbe41f7 100644 --- a/packages/registry-client/README.md +++ b/packages/registry-client/README.md @@ -20,13 +20,13 @@ Persists a publisher's atproto session between CLI invocations. Three implementa ### Publishing (`@emdash-cms/registry-client/publishing`) -Repo operations against the publisher's own PDS: `putRecord`, `uploadBlob`, `getRecord`, `listRecords`. Used by the CLI's `emdash-registry publish` flow. +Repo operations against the publisher's own PDS: `putRecord`, `uploadBlob`, `getRecord`, `listRecords`. Used by the CLI's `emdash-plugin publish` flow. The interactive OAuth flow lives in the CLI, not here. This module accepts a pre-built atproto fetch handler (typically from `@atcute/oauth-node-client`) and wraps it with operations scoped to atproto repo NSIDs. ### Discovery (`@emdash-cms/registry-client/discovery`) -Read-only XRPC client over an aggregator. No authentication. Used by the CLI (`emdash-registry search`, `emdash-registry info`) and the EmDash admin UI's install flow. +Read-only XRPC client over an aggregator. No authentication. Used by the CLI (`emdash-plugin search`, `emdash-plugin info`) and the EmDash admin UI's install flow. The `acceptLabelers` option threads the `atproto-accept-labelers` request header through every call so callers can configure which labellers' hard-takedown labels the aggregator should apply. diff --git a/packages/registry-client/src/credentials/file.ts b/packages/registry-client/src/credentials/file.ts index 93e63f70a..cb613f540 100644 --- a/packages/registry-client/src/credentials/file.ts +++ b/packages/registry-client/src/credentials/file.ts @@ -157,7 +157,7 @@ export class FileCredentialStore implements CredentialStore { // their CLI or remove the file manually. if (!Number.isInteger(parsed.version) || parsed.version < 1 || parsed.version > FILE_VERSION) { throw new Error( - `credential store at ${this.path} has version ${parsed.version}; this CLI understands versions 1..${FILE_VERSION}. Upgrade emdash-registry or remove the file manually.`, + `credential store at ${this.path} has version ${parsed.version}; this CLI understands versions 1..${FILE_VERSION}. Upgrade emdash-plugin or remove the file manually.`, ); } // Future: branch on parsed.version < FILE_VERSION for migrations. diff --git a/packages/registry-client/src/credentials/types.ts b/packages/registry-client/src/credentials/types.ts index 811c371d3..5d3cdd037 100644 --- a/packages/registry-client/src/credentials/types.ts +++ b/packages/registry-client/src/credentials/types.ts @@ -1,7 +1,7 @@ /** * Credential shapes shared between the credential store, the publishing * client, and the CLI. These describe what we persist between an interactive - * `emdash-registry login` and subsequent CLI invocations. + * `emdash-plugin login` and subsequent CLI invocations. * * The store itself is implementation-defined (filesystem on disk, in-memory * for tests, env-vars for CI). All implementations satisfy `CredentialStore`. diff --git a/packages/registry-client/src/index.ts b/packages/registry-client/src/index.ts index 9be695070..365afe71d 100644 --- a/packages/registry-client/src/index.ts +++ b/packages/registry-client/src/index.ts @@ -8,10 +8,10 @@ * env-vars (CI), in-memory (tests). * - **Publishing** (`./publishing`): repo operations against the publisher's * own PDS using a session built by `@atcute/oauth-node-client`. Used by - * the CLI's `emdash-registry publish` flow. + * the CLI's `emdash-plugin publish` flow. * - **Discovery** (`./discovery`): read-only XRPC client over an aggregator. - * No authentication. Used by both the CLI (`emdash-registry search` / - * `emdash-registry info`) and the EmDash admin UI's install flow. + * No authentication. Used by both the CLI (`emdash-plugin search` / + * `emdash-plugin info`) and the EmDash admin UI's install flow. * * The two halves are deliberately decoupled so consumers that only need * discovery (most notably the admin UI) don't have to pull in the publishing diff --git a/packages/registry-client/src/publishing/index.ts b/packages/registry-client/src/publishing/index.ts index c45f69ebe..e464b21f2 100644 --- a/packages/registry-client/src/publishing/index.ts +++ b/packages/registry-client/src/publishing/index.ts @@ -6,7 +6,7 @@ * what was just written. * * This module deliberately does NOT implement the interactive OAuth flow - * itself. Callers (the CLI in `@emdash-cms/registry-cli`) are responsible for: + * itself. Callers (the CLI in `@emdash-cms/plugin-cli`) are responsible for: * 1. Driving the OAuth dance (browser-redirect with device-flow fallback, * DPoP-bound tokens) via `@atcute/oauth-node-client`. * 2. Persisting the resulting session somewhere durable. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a15b3262..5dcaaf36d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,6 +210,9 @@ catalogs: better-sqlite3: specifier: ^12.8.0 version: 12.8.0 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -384,15 +387,15 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/registry-cli': - specifier: workspace:* - version: link:../../packages/registry-cli '@tanstack/react-query': specifier: 'catalog:' version: 5.90.21(react@19.2.4) @@ -473,15 +476,15 @@ importers: '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-embeds': specifier: workspace:* version: link:../../packages/plugins/embeds '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/registry-cli': - specifier: workspace:* - version: link:../../packages/registry-cli '@tanstack/react-query': specifier: 'catalog:' version: 5.90.21(react@19.2.4) @@ -602,12 +605,12 @@ importers: '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-color': specifier: workspace:* version: link:../../packages/plugins/color - '@emdash-cms/registry-cli': - specifier: workspace:* - version: link:../../packages/registry-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -757,15 +760,15 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/registry-cli': - specifier: workspace:* - version: link:../../packages/registry-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -800,15 +803,15 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/registry-cli': - specifier: workspace:* - version: link:../../packages/registry-cli astro: specifier: https://pkg.pr.new/astro@94d342d version: https://pkg.pr.new/astro@94d342d(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -1044,7 +1047,7 @@ importers: version: 4.2.1 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1139,7 +1142,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1231,7 +1234,7 @@ importers: version: 19.2.4(react@19.2.4) tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1314,7 +1317,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1339,7 +1342,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1526,7 +1529,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1564,7 +1567,7 @@ importers: version: 24.10.13 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1589,7 +1592,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1631,6 +1634,82 @@ importers: specifier: 'catalog:' version: 4.90.0(@cloudflare/workers-types@4.20260305.1) + packages/plugin-cli: + dependencies: + '@atcute/client': + specifier: 'catalog:' + version: 4.2.1 + '@atcute/identity-resolver': + specifier: 'catalog:' + version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@5.9.3))(@atcute/lexicons@1.3.0)(typescript@5.9.3) + '@atcute/lexicons': + specifier: 'catalog:' + version: 1.3.0 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 + '@atcute/oauth-node-client': + specifier: 'catalog:' + version: 1.1.0 + '@clack/prompts': + specifier: ^1.4.0 + version: 1.4.0 + '@emdash-cms/plugin-types': + specifier: workspace:* + version: link:../plugin-types + '@emdash-cms/registry-client': + specifier: workspace:* + version: link:../registry-client + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../registry-lexicons + '@oslojs/crypto': + specifier: 'catalog:' + version: 1.0.1 + chokidar: + specifier: 'catalog:' + version: 5.0.0 + citty: + specifier: ^0.1.6 + version: 0.1.6 + consola: + specifier: ^3.4.2 + version: 3.4.2 + image-size: + specifier: ^2.0.2 + version: 2.0.2 + jsonc-parser: + specifier: 'catalog:' + version: 3.3.1 + modern-tar: + specifier: ^0.7.5 + version: 0.7.6 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + zod: + specifier: 'catalog:' + version: 4.4.1 + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + publint: + specifier: 'catalog:' + version: 0.3.17 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + packages/plugin-types: devDependencies: '@arethetypeswrong/cli': @@ -1641,7 +1720,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1689,12 +1768,18 @@ importers: packages/plugins/atproto: dependencies: emdash: - specifier: workspace:* + specifier: workspace:>=0.10.0 version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli jsonc-parser: specifier: 'catalog:' version: 3.3.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1705,9 +1790,15 @@ importers: packages/plugins/audit-log: dependencies: emdash: - specifier: workspace:* + specifier: workspace:>=0.10.0 version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1808,6 +1899,12 @@ importers: specifier: workspace:* version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1818,6 +1915,12 @@ importers: specifier: workspace:* version: link:../../core devDependencies: + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../plugin-cli + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1825,85 +1928,18 @@ importers: packages/plugins/webhook-notifier: dependencies: emdash: - specifier: workspace:* + specifier: workspace:>=0.10.0 version: link:../../core devDependencies: - typescript: - specifier: 'catalog:' - version: 5.9.3 - - packages/registry-cli: - dependencies: - '@atcute/client': - specifier: 'catalog:' - version: 4.2.1 - '@atcute/identity-resolver': - specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@5.9.3))(@atcute/lexicons@1.3.0)(typescript@5.9.3) - '@atcute/lexicons': - specifier: 'catalog:' - version: 1.3.0 - '@atcute/multibase': - specifier: 'catalog:' - version: 1.2.0 - '@atcute/oauth-node-client': - specifier: 'catalog:' - version: 1.1.0 - '@clack/prompts': - specifier: ^1.4.0 - version: 1.4.0 - '@emdash-cms/plugin-types': + '@emdash-cms/plugin-cli': specifier: workspace:* - version: link:../plugin-types - '@emdash-cms/registry-client': - specifier: workspace:* - version: link:../registry-client - '@emdash-cms/registry-lexicons': - specifier: workspace:* - version: link:../registry-lexicons - '@oslojs/crypto': - specifier: 'catalog:' - version: 1.0.1 - citty: - specifier: ^0.1.6 - version: 0.1.6 - consola: - specifier: ^3.4.2 - version: 3.4.2 - image-size: - specifier: ^2.0.2 - version: 2.0.2 - jsonc-parser: - specifier: 'catalog:' - version: 3.3.1 - modern-tar: - specifier: ^0.7.5 - version: 0.7.6 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 + version: link:../../plugin-cli tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) - zod: - specifier: 'catalog:' - version: 4.4.1 - devDependencies: - '@arethetypeswrong/cli': - specifier: 'catalog:' - version: 0.18.2 - '@types/node': - specifier: 'catalog:' - version: 24.10.13 - publint: - specifier: 'catalog:' - version: 0.3.17 + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) packages/registry-client: dependencies: @@ -1931,7 +1967,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1959,7 +1995,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1987,7 +2023,7 @@ importers: version: 0.3.17 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -13241,14 +13277,20 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.5': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': @@ -15095,7 +15137,7 @@ snapshots: '@astrojs/markdown-remark': 7.0.0-beta.11 '@astrojs/telemetry': 3.3.0 '@capsizecss/unpack': 4.0.0 - '@clack/prompts': 1.1.0 + '@clack/prompts': 1.4.0 '@oslojs/encoding': 1.1.0 '@rollup/pluginutils': 5.3.0(rollup@4.55.2) aria-query: 5.3.2 @@ -18832,7 +18874,7 @@ snapshots: reusify@1.0.4: {} - rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@5.9.3): + rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.1 '@babel/helper-validator-identifier': 8.0.0-rc.1 @@ -18843,7 +18885,7 @@ snapshots: dts-resolver: 2.1.3(oxc-resolver@11.16.4) get-tsconfig: 4.13.6 obug: 2.1.1 - rolldown: 1.0.0-rc.3 + rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260213.1 typescript: 5.9.3 @@ -18871,7 +18913,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 - rolldown@1.0.0-rc.3: + rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.112.0 '@rolldown/pluginutils': 1.0.0-rc.3 @@ -18886,11 +18928,14 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - rolldown@1.0.0-rc.5: + rolldown@1.0.0-rc.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: '@oxc-project/types': 0.114.0 '@rolldown/pluginutils': 1.0.0-rc.5 @@ -18905,9 +18950,12 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.5 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.5 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.5 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.5 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.5 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' rollup@4.55.2: dependencies: @@ -19420,7 +19468,7 @@ snapshots: optionalDependencies: typescript: 6.0.0-beta - tsdown@0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3): + tsdown@0.20.3(@arethetypeswrong/core@0.18.2)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -19430,19 +19478,21 @@ snapshots: import-without-cache: 0.2.5 obug: 2.1.1 picomatch: 4.0.4 - rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@5.9.3) + rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 tree-kill: 1.2.2 unconfig-core: 7.4.2 - unrun: 0.2.28 + unrun: 0.2.28(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optionalDependencies: '@arethetypeswrong/core': 0.18.2 publint: 0.3.17 typescript: 5.9.3 transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - '@ts-macro/tsc' - '@typescript/native-preview' - oxc-resolver @@ -19599,9 +19649,12 @@ snapshots: unpipe@1.0.0: {} - unrun@0.2.28: + unrun@0.2.28(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): dependencies: - rolldown: 1.0.0-rc.5 + rolldown: 1.0.0-rc.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' unstorage@1.17.4: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f90a3b0c..16c125264 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,75 +16,76 @@ packages: - infra/plugins-site catalog: - "@arethetypeswrong/cli": ^0.18.2 - "@astrojs/check": ^0.9.7 - "@astrojs/cloudflare": ^13.1.7 - "@astrojs/node": ^10.0.0 - "@astrojs/react": ^5.0.0 - "@atcute/atproto": ^3.1.11 - "@atcute/car": ^6.0.0 - "@atcute/cbor": ^2.3.3 - "@atcute/cid": ^2.4.1 - "@atcute/client": ^4.2.1 - "@atcute/crypto": ^2.4.1 - "@atcute/firehose": ^1.0.0 - "@atcute/identity": ^2.0.0 - "@atcute/identity-resolver": ^2.0.0 - "@atcute/jetstream": ^2.0.0 - "@atcute/lex-cli": ^2.8.1 - "@atcute/lexicons": ^1.3.0 - "@atcute/mst": ^1.0.1 - "@atcute/multibase": ^1.2.0 - "@atcute/oauth-node-client": ^1.1.0 - "@atcute/repo": ^1.0.0 - "@atcute/xrpc-server": ^2.0.0 - "@atcute/xrpc-server-cloudflare": ^2.0.0 - "@atproto/crypto": ^0.4.5 - "@atproto/repo": ^0.9.1 - "@cloudflare/kumo": ^1.16.0 - "@cloudflare/vite-plugin": ^1.36.3 - "@cloudflare/vitest-pool-workers": ^0.16.3 - "@cloudflare/workers-types": ^4.20260305.1 - "@iconify-json/ph": ^1.2.2 - "@lingui/babel-plugin-lingui-macro": ^5.9.4 - "@lingui/cli": ^5.9.4 - "@lingui/conf": ^5.9.4 - "@lingui/core": ^5.9.4 - "@lingui/macro": ^5.9.4 - "@lingui/react": ^5.9.4 - "@oslojs/crypto": ^1.0.1 - "@oslojs/encoding": ^1.1.0 - "@oslojs/webauthn": ^1.0.0 - "@phosphor-icons/react": ^2.1.10 - "@tanstack/react-query": 5.90.21 - "@tanstack/react-router": 1.163.2 - "@tiptap/core": ^3.20.0 - "@tiptap/extension-character-count": ^3.20.0 - "@tiptap/extension-drag-handle": ^3.20.0 - "@tiptap/extension-drag-handle-react": ^3.20.0 - "@tiptap/extension-dropcursor": ^3.20.0 - "@tiptap/extension-focus": ^3.20.0 - "@tiptap/extension-image": ^3.20.0 - "@tiptap/extension-link": ^3.20.0 - "@tiptap/extension-node-range": ^3.20.0 - "@tiptap/extension-placeholder": ^3.20.0 - "@tiptap/extension-table": ^3.20.0 - "@tiptap/extension-table-cell": ^3.20.0 - "@tiptap/extension-table-header": ^3.20.0 - "@tiptap/extension-table-row": ^3.20.0 - "@tiptap/extension-text-align": ^3.20.0 - "@tiptap/extension-typography": ^3.20.0 - "@tiptap/extension-underline": ^3.20.0 - "@tiptap/pm": ^3.20.0 - "@tiptap/react": ^3.20.0 - "@tiptap/starter-kit": ^3.20.0 - "@tiptap/suggestion": ^3.20.0 - "@types/node": 24.10.13 - "@types/react": 19.2.14 - "@types/react-dom": 19.2.3 + '@arethetypeswrong/cli': ^0.18.2 + '@astrojs/check': ^0.9.7 + '@astrojs/cloudflare': ^13.1.7 + '@astrojs/node': ^10.0.0 + '@astrojs/react': ^5.0.0 + '@atcute/atproto': ^3.1.11 + '@atcute/car': ^6.0.0 + '@atcute/cbor': ^2.3.3 + '@atcute/cid': ^2.4.1 + '@atcute/client': ^4.2.1 + '@atcute/crypto': ^2.4.1 + '@atcute/firehose': ^1.0.0 + '@atcute/identity': ^2.0.0 + '@atcute/identity-resolver': ^2.0.0 + '@atcute/jetstream': ^2.0.0 + '@atcute/lex-cli': ^2.8.1 + '@atcute/lexicons': ^1.3.0 + '@atcute/mst': ^1.0.1 + '@atcute/multibase': ^1.2.0 + '@atcute/oauth-node-client': ^1.1.0 + '@atcute/repo': ^1.0.0 + '@atcute/xrpc-server': ^2.0.0 + '@atcute/xrpc-server-cloudflare': ^2.0.0 + '@atproto/crypto': ^0.4.5 + '@atproto/repo': ^0.9.1 + '@cloudflare/kumo': ^1.16.0 + '@cloudflare/vite-plugin': ^1.36.3 + '@cloudflare/vitest-pool-workers': ^0.16.3 + '@cloudflare/workers-types': ^4.20260305.1 + '@iconify-json/ph': ^1.2.2 + '@lingui/babel-plugin-lingui-macro': ^5.9.4 + '@lingui/cli': ^5.9.4 + '@lingui/conf': ^5.9.4 + '@lingui/core': ^5.9.4 + '@lingui/macro': ^5.9.4 + '@lingui/react': ^5.9.4 + '@oslojs/crypto': ^1.0.1 + '@oslojs/encoding': ^1.1.0 + '@oslojs/webauthn': ^1.0.0 + '@phosphor-icons/react': ^2.1.10 + '@tanstack/react-query': 5.90.21 + '@tanstack/react-router': 1.163.2 + '@tiptap/core': ^3.20.0 + '@tiptap/extension-character-count': ^3.20.0 + '@tiptap/extension-drag-handle': ^3.20.0 + '@tiptap/extension-drag-handle-react': ^3.20.0 + '@tiptap/extension-dropcursor': ^3.20.0 + '@tiptap/extension-focus': ^3.20.0 + '@tiptap/extension-image': ^3.20.0 + '@tiptap/extension-link': ^3.20.0 + '@tiptap/extension-node-range': ^3.20.0 + '@tiptap/extension-placeholder': ^3.20.0 + '@tiptap/extension-table': ^3.20.0 + '@tiptap/extension-table-cell': ^3.20.0 + '@tiptap/extension-table-header': ^3.20.0 + '@tiptap/extension-table-row': ^3.20.0 + '@tiptap/extension-text-align': ^3.20.0 + '@tiptap/extension-typography': ^3.20.0 + '@tiptap/extension-underline': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@tiptap/react': ^3.20.0 + '@tiptap/starter-kit': ^3.20.0 + '@tiptap/suggestion': ^3.20.0 + '@types/node': 24.10.13 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3 astro: ^6.0.1 astro-iconset: ^0.0.4 better-sqlite3: ^12.8.0 + chokidar: ^5.0.0 jsonc-parser: ^3.3.1 publint: 0.3.17 react: 19.2.4 diff --git a/templates/blank/.agents/skills/building-emdash-site/references/configuration.md b/templates/blank/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/blank/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/blank/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/blog-cloudflare/astro.config.mjs b/templates/blog-cloudflare/astro.config.mjs index 664fbbe25..1b87b1d75 100644 --- a/templates/blog-cloudflare/astro.config.mjs +++ b/templates/blog-cloudflare/astro.config.mjs @@ -2,7 +2,7 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; +import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; @@ -19,7 +19,7 @@ export default defineConfig({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }), plugins: [formsPlugin()], - sandboxed: [webhookNotifierPlugin()], + sandboxed: [webhookNotifier], sandboxRunner: sandbox(), marketplace: "https://marketplace.emdashcms.com", }), diff --git a/templates/blog/.agents/skills/building-emdash-site/references/configuration.md b/templates/blog/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/blog/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/blog/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/blog/astro.config.mjs b/templates/blog/astro.config.mjs index 662168127..726b23cf5 100644 --- a/templates/blog/astro.config.mjs +++ b/templates/blog/astro.config.mjs @@ -1,6 +1,6 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; import { defineConfig, fontProviders } from "astro/config"; import emdash, { local } from "emdash/astro"; import { sqlite } from "emdash/db"; @@ -22,7 +22,7 @@ export default defineConfig({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ], fonts: [ diff --git a/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/marketing-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md b/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/marketing/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/portfolio-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md b/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/portfolio/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/starter-cloudflare/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` diff --git a/templates/starter/.agents/skills/building-emdash-site/references/configuration.md b/templates/starter/.agents/skills/building-emdash-site/references/configuration.md index 6f4685c61..a1eb1eca4 100644 --- a/templates/starter/.agents/skills/building-emdash-site/references/configuration.md +++ b/templates/starter/.agents/skills/building-emdash-site/references/configuration.md @@ -113,12 +113,12 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings: Register plugins in `astro.config.mjs`: ```javascript -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; +import auditLog from "@emdash-cms/plugin-audit-log"; emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), - plugins: [auditLogPlugin()], + plugins: [auditLog], }), ``` From 0ccea49e97ed3367a0428f2b79ea53bdb87fd619 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 15 May 2026 21:57:41 +0100 Subject: [PATCH 07/16] fix(plugin-cli): adversarial review fixes - init scaffold emits the new `satisfies SandboxedPlugin` shape and npm-shape package.json (build/dev scripts, ./sandbox export, plugin-cli devDep) instead of the broken `definePlugin` template - publish reads package.json#version and reconciles via normaliseManifest so the new "version in package.json only" pattern actually publishes; malformed package.json surfaces a CliError, not a misleading VERSION_MISSING further down - dev watcher serialises rebuilds (queue collapsed to one follow-up), closes the watcher before draining pending on Ctrl-C, short-circuits scheduleRebuild during shutdown, handles Windows path separators in the outDir ignore glob, clears pending+queuedTrigger in finally so an IIFE rejection can't deadlock the session, and removes SIGINT handlers on shutdown - adapter normalises ctx.request to SandboxedRequest shape in-process so handlers see the same { url, method, headers: Record } promised by the strict type; null/array/non-object default exports rejected with a plugin-id-bearing message - build's readPackageMeta rejects empty/non-string version with the same strictness as publish, killing the build-pass/publish-fail asymmetry - pipeline probe rejects invalid hook config (errorPolicy, priority, timeout) so untyped JS authors get a build error rather than a silently-wrong runtime contract - versionless minimal-plugin fixture so bundle/publish/build integration tests exercise the package.json-as-source-of-truth path - definePlugin error wording softened for native-plugin authors whose id field has a typo - pipeline error messages and stale comments updated for the no-shim, no-definePlugin authoring shape - removed dead EMDASH_SHIM from the Cloudflare sandbox runner - changesets retargeted to @emdash-cms/plugin-cli; scaffold/atproto/core comments scrubbed for stale registry-cli references --- .changeset/busy-rivers-drive.md | 6 +- packages/cloudflare/src/sandbox/runner.ts | 3 - packages/core/src/api/handlers/registry.ts | 4 +- .../core/src/plugins/adapt-sandbox-entry.ts | 50 +++++- packages/core/src/plugins/define-plugin.ts | 10 +- packages/core/src/plugins/types.ts | 2 +- packages/plugin-cli/src/build/api.ts | 4 +- packages/plugin-cli/src/build/pipeline.ts | 82 ++++++++-- packages/plugin-cli/src/commands/publish.ts | 70 ++++++++- packages/plugin-cli/src/dev/command.ts | 146 +++++++++++++++--- packages/plugin-cli/src/init/templates.ts | 81 ++++++---- packages/plugin-cli/src/manifest/translate.ts | 24 ++- .../minimal-plugin/emdash-plugin.jsonc | 1 - .../plugin-cli/tests/init-scaffold.test.ts | 4 +- .../plugin-cli/tests/init-templates.test.ts | 48 ++++-- packages/plugin-types/src/index.ts | 2 +- packages/plugins/atproto/src/plugin.ts | 2 +- 17 files changed, 437 insertions(+), 102 deletions(-) diff --git a/.changeset/busy-rivers-drive.md b/.changeset/busy-rivers-drive.md index b26dc3c51..49c46a79d 100644 --- a/.changeset/busy-rivers-drive.md +++ b/.changeset/busy-rivers-drive.md @@ -1,11 +1,11 @@ --- -"@emdash-cms/registry-cli": minor +"@emdash-cms/plugin-cli": minor --- Adds `emdash-plugin.jsonc` manifest support. Plugin authors can now declare profile fields (license, author, security contact, name, description, keywords, repo) once in a hand-edited JSONC file instead of passing them as flags on every publish. The CLI loads `./emdash-plugin.jsonc` automatically; explicit flags still win for CI use. -New `emdash-registry validate` command checks a manifest against the schema offline with `tsc`-style file:line:column diagnostics. +New `emdash-plugin validate` command checks a manifest against the schema offline with `tsc`-style file:line:column diagnostics. The manifest's optional `publisher` field pins the publishing identity. On first successful publish, the CLI writes the active session's DID back to the manifest. Subsequent publishes verify the active session matches the pinned publisher and refuse on mismatch to prevent accidental cross-account publishes. -JSON Schema for IDE completion ships in the package at `schemas/emdash-plugin.schema.json`; reference it via `"$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json"`. +JSON Schema for IDE completion ships in the package at `schemas/emdash-plugin.schema.json`; reference it via `"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json"`. diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index 3729c7723..fcf45df60 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -27,8 +27,6 @@ import { setEmailSendCallback } from "./bridge.js"; import type { WorkerLoader, WorkerStub, PluginBridgeBinding, WorkerLoaderLimits } from "./types.js"; import { generatePluginWrapper } from "./wrapper.js"; -const EMDASH_SHIM = "export const definePlugin = (d) => d;\n"; - /** * Default resource limits for sandboxed plugins. * @@ -262,7 +260,6 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance { modules: { "plugin.js": { js: this.wrapperCode! }, "sandbox-plugin.js": { js: this.code }, - emdash: { js: EMDASH_SHIM }, }, // Block direct network access - plugins must use ctx.http via bridge globalOutbound: null, diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index dec415532..a8e9ac98f 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -126,7 +126,7 @@ const MULTIHASH_SHA256_LENGTH = 0x20; /** * Compute the multibase-multihash sha2-256 checksum of `bytes`, in the * same `b` shape the registry CLI publishes - * (`packages/registry-cli/src/multihash.ts`). Returns a 56-character + * (`packages/plugin-cli/src/multihash.ts`). Returns a 56-character * string starting with `b`. * * The trust contract is: if both sides produce the same string for @@ -157,7 +157,7 @@ async function sha256MultibaseMultihash(bytes: Uint8Array): Promise { * publishers / tools that emit hex rather than multibase. * - Multibase-multihash with the `b` (base32) prefix and sha2-256. * This is the format RFC 0001 mandates and the registry CLI emits - * (see `packages/registry-cli/src/multihash.ts`). + * (see `packages/plugin-cli/src/multihash.ts`). * * Hash functions other than sha2-256 are out of scope for this * initial release; the install fails closed. diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 139964f48..f0546424d 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -126,6 +126,18 @@ export function adaptSandboxEntry( const pluginId = descriptor.id; const version = descriptor.version; + // A null / array / non-object `definition` would throw a generic + // `TypeError: Cannot read properties of null` further down the + // loop without the plugin id; surface a useful error first. + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { + throw new Error( + `Plugin "${pluginId}" default export must be an object with ` + + `\`hooks\` and/or \`routes\` (got ${ + Array.isArray(definition) ? "array" : typeof definition + }). Did you forget \`export default {...} satisfies SandboxedPlugin\`?`, + ); + } + // Resolve hooks. `SandboxedPlugin.hooks` is keyed by hook name with // per-key entry types; iterating with `Object.entries` collapses // keys to `string`, so we treat each entry as the union `AnyHookEntry` @@ -177,9 +189,45 @@ export function adaptSandboxEntry( input: inputSchema as PluginRoute["input"], public: publicFlag, handler: async (ctx) => { + // In-process, `ctx.request` is a real WHATWG `Request` + // with a `Headers` object. The author-facing + // `SandboxedRequest` type promises a plain + // `Record` (the shape the sandbox's + // serialised form delivers). Normalise so handlers + // behave the same in-process and in-isolate. + const headers: Record = {}; + if (ctx.request && typeof ctx.request === "object") { + const h: unknown = (ctx.request as { headers?: unknown }).headers; + if (h && typeof h === "object") { + if (typeof (h as Headers).forEach === "function") { + (h as Headers).forEach((value, name) => { + headers[name] = value; + }); + } else { + for (const [name, value] of Object.entries( + h as Record, + )) { + headers[name] = value; + } + } + } + } + const requestShape = { + url: + (ctx.request as { url?: unknown } | undefined)?.url && + typeof (ctx.request as { url: unknown }).url === "string" + ? (ctx.request as { url: string }).url + : "", + method: + (ctx.request as { method?: unknown } | undefined)?.method && + typeof (ctx.request as { method: unknown }).method === "string" + ? (ctx.request as { method: string }).method + : "GET", + headers, + }; const routeCtx = { input: ctx.input, - request: ctx.request, + request: requestShape, requestMeta: ctx.requestMeta, }; const { input: _, request: __, requestMeta: ___, ...pluginCtx } = ctx; diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 30760d732..98e9682e3 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -78,11 +78,11 @@ export function definePlugin( // caller meant the sandboxed authoring flow. if (typeof definition.id !== "string" || definition.id.length === 0) { throw new Error( - "definePlugin() is for native-format plugins and requires `id`. " + - "Sandboxed plugins use the default-export shape with " + - '`satisfies SandboxedPlugin` from "emdash/plugin"; identity ' + - "comes from `emdash-plugin.jsonc` (slug + publisher), not the " + - "source file.", + `definePlugin() requires \`id\` (got ${typeof definition.id}). ` + + "For native plugins, make sure your definition has both `id` and " + + "`version`. For sandboxed plugins, drop `definePlugin()` entirely " + + 'and `export default { hooks, routes } satisfies SandboxedPlugin` ' + + 'from "emdash/plugin" — identity comes from `emdash-plugin.jsonc`.', ); } return defineNativePlugin(definition); diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 3a3801d24..b743ed1b8 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -58,7 +58,7 @@ export { // // `StorageCollectionConfig` and `PluginStorageConfig` are re-exported above // from `@emdash-cms/plugin-types`. The manifest carries these shapes -// verbatim; both this package (reader) and registry-cli (writer) agree on +// verbatim; both this package (reader) and plugin-cli (writer) agree on // the same types via the shared package. /** diff --git a/packages/plugin-cli/src/build/api.ts b/packages/plugin-cli/src/build/api.ts index aa940d5ee..b6bf204d3 100644 --- a/packages/plugin-cli/src/build/api.ts +++ b/packages/plugin-cli/src/build/api.ts @@ -283,7 +283,9 @@ async function writeDescriptor(ctx: WriteDescriptorContext): Promise/plugin.mjs` - * and `/plugin.d.mts`. Same shim as the probe so the input is - * identical. + * again, this time minified + tree-shaken + with `.d.mts` types, to + * produce `/plugin.mjs` and `/plugin.d.mts`. Probe + * and runtime builds differ deliberately in minification and dts + * output; the probe only reads `default.hooks` / `default.routes` + * *keys*, which minification doesn't rename (object literal keys + * stay stable). Both pass the same source through tsdown with no + * `external` and no `alias` — sandboxed plugins must not import + * from `emdash` at runtime (types come from `emdash/plugin` and + * are erased before bundling). * * Errors throw `BuildPipelineError` with a structured code. Wrappers translate * to their own error classes so the CLI's `BuildError` / `BundleError` @@ -204,9 +210,21 @@ async function readPackageMeta(packageJsonPath: string): Promise { `${packageJsonPath} has no "name" field. The build derives the runtime entrypoint specifier from package.json#name.`, ); } + // `version` is optional (registry-only plugins may rely on the + // manifest's version); when present, it must be a non-empty + // string. const versionRaw = (parsed as { version?: unknown }).version; - const packageVersion = - typeof versionRaw === "string" && versionRaw.length > 0 ? versionRaw : undefined; + let packageVersion: string | undefined; + if (versionRaw === undefined) { + packageVersion = undefined; + } else if (typeof versionRaw === "string" && versionRaw.length > 0) { + packageVersion = versionRaw; + } else { + throw new BuildPipelineError( + "PACKAGE_JSON_INVALID", + `${packageJsonPath} has a non-string or empty \`version\` (${JSON.stringify(versionRaw)}). Either remove the field (registry-only plugins) or set it to a non-empty string.`, + ); + } return { packageName: name, packageVersion }; } @@ -282,10 +300,10 @@ export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise; const definition = (pluginModule.default ?? {}) as Record; - if (typeof definition !== "object" || definition === null) { + if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { throw new BuildPipelineError( "INVALID_PLUGIN_FORMAT", - `${entries.pluginEntry} must default-export the result of definePlugin({ hooks, routes }). Got ${describeShape(definition)}.`, + `${entries.pluginEntry} must default-export an object with \`hooks\` and/or \`routes\` (sandboxed plugin shape: \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin"). Got ${describeShape(definition)}.`, ); } @@ -306,6 +324,38 @@ export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise) : {}; + // Re-validate hook config values at build time. The strict + // `SandboxedPlugin` type rejects these at compile time; + // this catches authors who bypass typecheck (untyped JS, + // dynamic config). + if ( + config.errorPolicy !== undefined && + config.errorPolicy !== "continue" && + config.errorPolicy !== "abort" + ) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" has invalid errorPolicy ${JSON.stringify(config.errorPolicy)} (must be "continue" or "abort").`, + ); + } + if ( + config.priority !== undefined && + (typeof config.priority !== "number" || !Number.isFinite(config.priority)) + ) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" has invalid priority ${JSON.stringify(config.priority)} (must be a finite number).`, + ); + } + if ( + config.timeout !== undefined && + (typeof config.timeout !== "number" || !Number.isFinite(config.timeout) || config.timeout < 0) + ) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry}: hook "${hookName}" has invalid timeout ${JSON.stringify(config.timeout)} (must be a non-negative finite number).`, + ); + } resolvedPlugin.hooks[hookName] = { handler, priority: (config.priority as number | undefined) ?? 100, @@ -357,9 +407,13 @@ export interface RuntimeFiles { /** * Build `src/plugin.ts` into `/plugin.mjs` + `/plugin.d.mts`. * - * Same `emdash` shim alias as the probe so the input is identical. - * Minified + tree-shaken because this output is what either runs in the - * isolate (loader string-embeds it) or is `import`-ed in-process. + * Same source as the probe; the configuration differs only in + * `minify: true` and `dts: true`. The probe stays unminified for + * stable property-key reads (`default.hooks`, `default.routes`); the + * runtime build minifies because this output is what runs in the + * isolate (loader string-embeds it) or is `import`-ed in-process. No + * `external`, no `alias` — sandboxed plugins must not import from + * `emdash` at runtime. */ export async function buildRuntime(ctx: BuildRuntimeContext): Promise { const { entries, outDir, tmpDir, build } = ctx; diff --git a/packages/plugin-cli/src/commands/publish.ts b/packages/plugin-cli/src/commands/publish.ts index 4b080c782..68b38d7f6 100644 --- a/packages/plugin-cli/src/commands/publish.ts +++ b/packages/plugin-cli/src/commands/publish.ts @@ -20,7 +20,7 @@ import { lookup as dnsLookup } from "node:dns/promises"; import { readFile, stat } from "node:fs/promises"; -import { resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import type { PluginManifest } from "@emdash-cms/plugin-types"; import { FileCredentialStore, PublishingClient } from "@emdash-cms/registry-client"; @@ -461,7 +461,22 @@ async function loadManifestBootstrap( const path = args.manifest ?? `./${MANIFEST_FILENAME}`; try { const { manifest, path: resolvedPath } = await loadManifest(path); - const normalised = normaliseManifest(manifest); + // Manifest `version` is optional; reconcile with + // `package.json#version` (the canonical source for + // npm-distributed plugins) before validating. + const packageVersion = await readSiblingPackageVersion(dirname(resolvedPath)); + let normalised: NormalisedManifest; + try { + normalised = normaliseManifest(manifest, packageVersion); + } catch (error) { + if (error instanceof Error && "code" in error) { + const code = (error as { code: unknown }).code; + if (code === "VERSION_MISSING" || code === "VERSION_MISMATCH") { + throw new CliError(error.message, 1, String(code)); + } + } + throw error; + } log.info(`Loaded manifest: ${pc.dim(resolvedPath)}`); return { path: resolvedPath, @@ -479,6 +494,57 @@ async function loadManifestBootstrap( } } +/** + * Read `package.json#version` from the directory containing the + * manifest. Returns `undefined` when no `package.json` exists (the + * registry-only path). Throws `CliError` when the file exists but is + * malformed, so a typo like `"verison"` surfaces directly rather than + * appearing as a misleading VERSION_MISSING further down. + */ +async function readSiblingPackageVersion(manifestDir: string): Promise { + const packageJsonPath = join(manifestDir, "package.json"); + let source: string; + try { + source = await readFile(packageJsonPath, "utf-8"); + } catch (error) { + // ENOENT is the registry-only path; surface anything else. + if (error instanceof Error && "code" in error && (error as { code: unknown }).code === "ENOENT") { + return undefined; + } + throw new CliError( + `Failed to read package.json at ${packageJsonPath}: ${error instanceof Error ? error.message : String(error)}`, + 1, + "PACKAGE_JSON_UNREADABLE", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch (error) { + throw new CliError( + `package.json at ${packageJsonPath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + 1, + "PACKAGE_JSON_INVALID", + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError( + `package.json at ${packageJsonPath} must be a JSON object.`, + 1, + "PACKAGE_JSON_INVALID", + ); + } + const version = (parsed as { version?: unknown }).version; + if (version !== undefined && (typeof version !== "string" || version.length === 0)) { + throw new CliError( + `package.json at ${packageJsonPath} has a non-string \`version\` (${JSON.stringify(version)}).`, + 1, + "PACKAGE_JSON_INVALID", + ); + } + return typeof version === "string" ? version : undefined; +} + // ── helpers ────────────────────────────────────────────────────────────────── /** diff --git a/packages/plugin-cli/src/dev/command.ts b/packages/plugin-cli/src/dev/command.ts index 2e01cc9ed..4e1e002e5 100644 --- a/packages/plugin-cli/src/dev/command.ts +++ b/packages/plugin-cli/src/dev/command.ts @@ -14,12 +14,26 @@ * until the next successful rebuild. * - Debounces rapid bursts (editors saving multiple files) at * 150ms so a single edit doesn't trigger several rebuilds. - * - SIGINT (Ctrl-C) closes the watcher cleanly and exits 0. + * - Serialises builds: if a change arrives while one is in flight, + * a follow-up build is queued and runs after the current one + * completes. This prevents a slow earlier build from overwriting + * dist/ with stale output after a newer build has already + * finished. + * - SIGINT (Ctrl-C) waits for any in-flight build before closing + * the watcher and exits 0. A second signal during shutdown + * forces immediate exit so an impatient Ctrl-Ctrl-C still works. * - * Distinct from `build` only in that it loops + watches. The build - * pipeline itself is identical. + * Known limitation: the build pipeline's probe step dynamically + * imports the freshly-built plugin module to harvest hook/route names. + * Each probe goes to a unique temp file, and Node's ESM loader caches + * modules by URL with no eviction. Across many rebuilds the loader's + * cache grows monotonically (each leaked module is small — kilobytes — + * but the count is unbounded). Restart `dev` after long sessions; a + * future refactor will harvest the surface via AST instead of import(). */ +import { isAbsolute, relative, resolve, sep } from "node:path"; + import { defineCommand } from "citty"; import consola from "consola"; import pc from "picocolors"; @@ -27,6 +41,10 @@ import pc from "picocolors"; import { BuildError, buildPlugin, type BuildLogger } from "../build/api.js"; const DEBOUNCE_MS = 150; +/** + * Files / globs the watcher tracks for change events. Relative to the + * plugin directory; chokidar's `cwd` option resolves them. + */ const WATCH_GLOBS = ["src/**", "emdash-plugin.jsonc", "package.json"]; export const devCommand = defineCommand({ @@ -50,6 +68,10 @@ export const devCommand = defineCommand({ async run({ args }) { const { default: chokidar } = await import("chokidar"); + // Lifted out so scheduleRebuild can short-circuit any + // post-Ctrl-C file events while the watcher is still draining. + let shutdownStarted = false; + const logger: BuildLogger = { start: (m) => consola.start(m), info: (m) => consola.info(m), @@ -57,7 +79,14 @@ export const devCommand = defineCommand({ warn: (m) => consola.warn(m), }; - const buildOnce = async (label: string): Promise => { + // Serialisation state. `pending` holds the in-flight build's + // promise; `queued` is a single follow-up trigger label + // (collapsing multiple-rebuilds-during-build into one). When + // the current build settles, the queued trigger fires. + let pending: Promise | undefined; + let queuedTrigger: string | undefined; + + const runBuild = async (label: string): Promise => { const stamp = new Date().toLocaleTimeString(); console.log(); console.log(pc.dim(`── ${label} at ${stamp} ─────────────────────`)); @@ -73,32 +102,87 @@ export const devCommand = defineCommand({ } else { consola.error(error instanceof Error ? error.message : String(error)); } - consola.info(pc.dim("Last successful build (if any) is still in dist/. Waiting for changes...")); + consola.info( + pc.dim("Last successful build (if any) is still in dist/. Waiting for changes..."), + ); + } + }; + + /** + * Schedule a build. If one's running, queue the trigger so we + * rebuild after it finishes; the queued trigger is collapsed + * (only the most recent change label survives). Otherwise + * starts immediately and tracks the promise in `pending` so + * subsequent triggers know to queue. + */ + const startBuild = (label: string): void => { + if (pending) { + queuedTrigger = label; + return; } + pending = (async () => { + // try/finally so state always clears even if a future + // runBuild throws past its own catch (e.g. logger + // write error during shutdown). A stuck `pending` + // would deadlock the watcher. + try { + let currentLabel = label; + while (currentLabel) { + await runBuild(currentLabel); + currentLabel = queuedTrigger ?? ""; + queuedTrigger = undefined; + } + } finally { + pending = undefined; + queuedTrigger = undefined; + } + })(); }; - // Initial build before starting the watcher. If it fails the watcher - // still starts so the author can fix the error and re-trigger. - await buildOnce("initial build"); + // Initial build before starting the watcher. If it fails the + // watcher still starts so the author can fix the error and + // re-trigger. Run synchronously so the user sees the result + // before we print "Watching". + await runBuild("initial build"); + + // Resolve outDir relative to the plugin dir so the ignore + // pattern matches whatever the user passed for `--outDir`. + // chokidar wants forward-slash globs even on Windows, so + // normalise the platform separator (path.sep) to "/". + const resolvedOutDir = resolve(args.dir, args.outDir); + const cwdAbs = resolve(args.dir); + const outDirRel = relative(cwdAbs, resolvedOutDir); + // `outDirGlob` is the ignore pattern only when outDir is + // strictly inside the watched dir. `relative` returns "" when + // the two paths are equal (outDir === plugin root, which would + // be pathological — would write plugin.mjs / manifest.json + // next to src/ and the watcher would loop). Empty string and + // upward / absolute paths fall through to `undefined`. + const outDirGlob = + outDirRel && !outDirRel.startsWith("..") && !isAbsolute(outDirRel) + ? `${outDirRel.split(sep).join("/")}/**` + : undefined; + + const ignored = ["**/node_modules/**", ...(outDirGlob ? [outDirGlob] : [])]; const watcher = chokidar.watch(WATCH_GLOBS, { cwd: args.dir, ignoreInitial: true, - // Don't watch our own output — it'd loop. - ignored: ["dist/**", "**/node_modules/**"], + ignored, }); let timer: NodeJS.Timeout | undefined; let pendingTrigger: string | undefined; const scheduleRebuild = (path: string) => { + if (shutdownStarted) return; pendingTrigger = path; if (timer) clearTimeout(timer); timer = setTimeout(() => { const trigger = pendingTrigger ?? "change"; pendingTrigger = undefined; timer = undefined; - void buildOnce(`rebuild (${trigger})`); + startBuild(`rebuild (${trigger})`); }, DEBOUNCE_MS); }; @@ -111,16 +195,42 @@ export const devCommand = defineCommand({ consola.info(`Watching ${pc.cyan(args.dir)} for changes (Ctrl-C to stop)`); - // SIGINT clean-up. Returns a promise that resolves when the user - // interrupts so we keep the watcher alive until then. - await new Promise((resolve) => { + // Shutdown waits for the in-flight build (if any) so the user + // doesn't end up with a torn dist/. A second SIGINT during + // shutdown forces immediate exit — impatience is a valid + // signal. + await new Promise((resolveOuter) => { const shutdown = () => { - consola.info("Stopping watcher..."); + if (shutdownStarted) { + consola.warn("Second interrupt — forcing exit."); + process.exit(130); + } + shutdownStarted = true; + consola.info("Stopping watcher (waiting for in-flight build)..."); if (timer) clearTimeout(timer); - void watcher.close().then(() => resolve()); + queuedTrigger = undefined; + const drainAndClose = async () => { + // Close the watcher before draining `pending` so a + // file change arriving during the wait can't queue + // a new build the user already cancelled. + await watcher.close(); + if (pending) { + try { + await pending; + } catch { + /* runBuild swallows its own errors */ + } + } + process.off("SIGINT", shutdown); + process.off("SIGTERM", shutdown); + resolveOuter(); + }; + void drainAndClose(); }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); + // Use `on` (not `once`) so a second signal during shutdown + // hits the same handler and the impatience branch fires. + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); }); }, }); diff --git a/packages/plugin-cli/src/init/templates.ts b/packages/plugin-cli/src/init/templates.ts index 2ad57bf57..a5ee74a02 100644 --- a/packages/plugin-cli/src/init/templates.ts +++ b/packages/plugin-cli/src/init/templates.ts @@ -6,18 +6,18 @@ * functions makes the scaffolder testable without touching disk and * keeps every template inspectable in one place. * - * The shape produced is the three-file authoring contract: + * The shape produced is the authoring contract: * * emdash-plugin.jsonc — identity + trust contract + profile - * src/plugin.ts — definePlugin({ routes, hooks }) - * package.json — private, type:module, devDeps only + * src/plugin.ts — `{ routes?, hooks? } satisfies SandboxedPlugin` + * package.json — type:module, devDep on @emdash-cms/plugin-cli * tsconfig.json — strict, standalone * .gitignore * README.md * tests/plugin.test.ts * - * No `src/index.ts`, no `dist/`, no tsdown. Source is the artefact; - * `emdash-plugin bundle` transpiles at publish time. + * No `src/index.ts`, no `dist/` in source control. `emdash-plugin build` + * generates `dist/` artefacts (plugin.mjs, manifest.json, index.mjs). */ import type { ManifestAuthor, ManifestSecurityContact } from "../manifest/schema.js"; @@ -94,7 +94,10 @@ export function renderManifest(input: ScaffoldInputs): string { ); lines.push(""); lines.push(`\t"slug": ${jsonString(input.slug)},`); - lines.push('\t"version": "0.1.0",'); + // `version` deliberately omitted — the build reads it from + // `package.json` so there's a single source of truth. Registry-only + // plugins (no package.json) would set it here, but the scaffold + // always emits one. if (!input.publisher) { lines.push( @@ -176,37 +179,42 @@ function renderSecurityContact(contact: ManifestSecurityContact): string { /** * `src/plugin.ts` — runtime code. One route, no hooks. Demonstrates the - * three primitives a sandboxed plugin author needs: definePlugin (the - * types helper), the PluginContext type, and a return value (the runtime - * routes it as JSON). + * two primitives a sandboxed plugin author needs: the strict + * `SandboxedPlugin` type (which infers handler signatures per hook / + * route name) and a default-exported `{ hooks?, routes? }` object. */ export function renderPluginEntry(): string { - return `import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; + return `import type { SandboxedPlugin } from "emdash/plugin"; /** - * Sandboxed plugin entry. \`definePlugin\` is a types helper — it - * passes the definition through verbatim and gives the TS compiler - * the right inference for hook and route handler signatures. + * Sandboxed plugin entry. The default export is a bare object; the + * \`satisfies SandboxedPlugin\` annotation gives TypeScript per-hook / + * per-route inference (\`ctx\` is \`PluginContext\` automatically; hook + * \`event\` parameters are typed by hook name). */ -export default definePlugin({ +export default { \troutes: { \t\thello: { -\t\t\thandler: async (_routeCtx, ctx: PluginContext) => { +\t\t\thandler: async (_routeCtx, ctx) => { \t\t\t\tctx.log.info("hello route called", { pluginId: ctx.plugin.id }); \t\t\t\treturn { greeting: "hello", pluginId: ctx.plugin.id }; \t\t\t}, \t\t}, \t}, -}); +} satisfies SandboxedPlugin; `; } /** - * `package.json` — toolchain only. Private (won't accidentally get - * published to npm), type:module (Node ESM), no main / exports / files / - * build scripts. The package exists for vitest + tsc to find their feet, - * not for distribution. + * `package.json` — npm-shape so the plugin is `pnpm add`-able. The + * scaffold sets `private: true` defensively; flip it off when you're + * ready to publish to npm. `version` here is the single source of + * truth — the build reads it and writes it into the bundled manifest. + * + * `./sandbox` export points at the built runtime bytes that both + * in-process and isolate loaders consume. `main` / `import` point at + * the auto-generated descriptor module the integration imports for + * default in `astro.config.mjs`. */ export function renderPackageJson(input: ScaffoldInputs): string { const pkg = { @@ -214,12 +222,27 @@ export function renderPackageJson(input: ScaffoldInputs): string { version: "0.1.0", private: true, type: "module", + main: "dist/index.mjs", + exports: { + ".": { + import: "./dist/index.mjs", + types: "./dist/index.d.mts", + }, + "./sandbox": "./dist/plugin.mjs", + }, + files: ["dist", "emdash-plugin.jsonc"], scripts: { + build: "emdash-plugin build", + dev: "emdash-plugin dev", typecheck: "tsc --noEmit", test: "vitest run", }, + peerDependencies: { + emdash: ">=0.12.0", + }, devDependencies: { - emdash: "^6.0.0", + "@emdash-cms/plugin-cli": ">=0.1.0", + emdash: ">=0.12.0", typescript: "^5.9.0", vitest: "^4.1.0", }, @@ -252,11 +275,11 @@ export function renderTsconfig(): string { } /** - * `.gitignore` — node_modules only. No `dist/` because there is no - * `dist/`. + * `.gitignore` — node_modules + dist (build output should not be + * committed; rebuild on every install). */ export function renderGitignore(): string { - return "node_modules/\n"; + return "node_modules/\ndist/\n"; } /** @@ -294,9 +317,11 @@ emdash-plugin publish --url https://your-host/... ## Version bumps -Bump \`version\` in \`emdash-plugin.jsonc\` (and \`package.json\`, for -tooling) when you ship a release. **Bump major** for breaking changes, -**bump minor** for new routes or hooks, **bump patch** for fixes. +Bump \`version\` in \`package.json\` when you ship a release. The +scaffold's \`emdash-plugin.jsonc\` deliberately omits \`version\` — +the build pipeline reads it from \`package.json\` so there's a single +source of truth. **Bump major** for breaking changes, **bump minor** +for new routes or hooks, **bump patch** for fixes. You MUST bump version whenever you change \`capabilities\`, \`allowedHosts\`, or \`storage\` in the manifest. Installed users have consented to the diff --git a/packages/plugin-cli/src/manifest/translate.ts b/packages/plugin-cli/src/manifest/translate.ts index ba5c9f8e6..b40256a7b 100644 --- a/packages/plugin-cli/src/manifest/translate.ts +++ b/packages/plugin-cli/src/manifest/translate.ts @@ -90,16 +90,30 @@ export class VersionMismatchError extends Error { * - Only one present → returns it. * - Neither present → throws `VERSION_MISSING`. * - * The "Changesets-driven" common case (manifest omits `version`, - * `package.json` carries it) and the "registry-only, no package.json" - * case (manifest carries `version`, no `package.json`) both produce a - * single source of truth without ceremony. The mismatch case is the - * one we want to fail loudly on — silent prefer-one creates drift. + * Surrounding whitespace on either input is rejected with a dedicated + * error so a visually-identical-but-not-equal pair like `"1.0.0 "` + * vs `"1.0.0"` doesn't print a confusing mismatch message. */ export function resolvePluginVersion( manifestVersion: string | undefined, packageVersion: string | undefined, ): string { + if (manifestVersion !== undefined && manifestVersion.trim() !== manifestVersion) { + throw new VersionMismatchError( + "VERSION_MISMATCH", + `Plugin version in emdash-plugin.jsonc has leading or trailing whitespace (${JSON.stringify(manifestVersion)}). Trim it.`, + manifestVersion, + packageVersion, + ); + } + if (packageVersion !== undefined && packageVersion.trim() !== packageVersion) { + throw new VersionMismatchError( + "VERSION_MISMATCH", + `Plugin version in package.json has leading or trailing whitespace (${JSON.stringify(packageVersion)}). Trim it.`, + manifestVersion, + packageVersion, + ); + } if (manifestVersion !== undefined && packageVersion !== undefined) { if (manifestVersion !== packageVersion) { throw new VersionMismatchError( diff --git a/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc b/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc index 7e89c684d..294391e6f 100644 --- a/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc +++ b/packages/plugin-cli/tests/fixtures/minimal-plugin/emdash-plugin.jsonc @@ -1,6 +1,5 @@ { "slug": "fixture-minimal", - "version": "1.2.3", "publisher": "fixture.example.com", "license": "MIT", "author": { "name": "Test Author" }, diff --git a/packages/plugin-cli/tests/init-scaffold.test.ts b/packages/plugin-cli/tests/init-scaffold.test.ts index 2215d09cd..4aa2c0d65 100644 --- a/packages/plugin-cli/tests/init-scaffold.test.ts +++ b/packages/plugin-cli/tests/init-scaffold.test.ts @@ -77,7 +77,9 @@ describe("scaffold", () => { await scaffold({ targetDir, inputs: FULL_INPUTS, force: false }); const { manifest } = await loadManifest(targetDir); expect(manifest.slug).toBe("gallery"); - expect(manifest.version).toBe("0.1.0"); + // `version` is intentionally omitted from the scaffold manifest; + // the build reads it from package.json instead. + expect(manifest.version).toBeUndefined(); expect(manifest.publisher).toBe("did:plc:abc123def456"); expect(manifest.license).toBe("MIT"); }); diff --git a/packages/plugin-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts index 227053f2b..37a1c9207 100644 --- a/packages/plugin-cli/tests/init-templates.test.ts +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -60,7 +60,9 @@ describe("renderManifest (fully-populated)", () => { it("renders identity, license, author, security, description, repo", () => { const source = renderManifest(FULL_INPUTS); expect(source).toContain('"slug": "gallery"'); - expect(source).toContain('"version": "0.1.0"'); + // `version` deliberately omitted from the manifest scaffold — + // package.json#version is the source of truth. + expect(source).not.toContain('"version":'); expect(source).toContain('"publisher": "did:plc:abc123def456"'); expect(source).toContain('"license": "MIT"'); expect(source).toContain('"name": "Jane Doe"'); @@ -161,23 +163,33 @@ describe("renderManifest (partial author/security)", () => { }); describe("renderPackageJson", () => { - it("uses the slug as the package name and marks it private", () => { + it("uses the slug as the package name and starts private", () => { const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); expect(parsed.name).toBe("gallery"); expect(parsed.private).toBe(true); expect(parsed.type).toBe("module"); }); - it("ships typecheck + test scripts only — no build", () => { + it("ships build/dev/typecheck/test scripts", () => { const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); - expect(Object.keys(parsed.scripts)).toEqual(["typecheck", "test"]); + expect(parsed.scripts.build).toBe("emdash-plugin build"); + expect(parsed.scripts.dev).toBe("emdash-plugin dev"); + expect(parsed.scripts.typecheck).toBeDefined(); + expect(parsed.scripts.test).toBeDefined(); }); - it("doesn't ship main, exports, or files (no npm publishing path)", () => { + it("ships npm-shape main/exports/files so the plugin is pnpm-add-able", () => { const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); - expect(parsed.main).toBeUndefined(); - expect(parsed.exports).toBeUndefined(); - expect(parsed.files).toBeUndefined(); + expect(parsed.main).toBe("dist/index.mjs"); + expect(parsed.exports["."]).toBeDefined(); + expect(parsed.exports["./sandbox"]).toBe("./dist/plugin.mjs"); + expect(parsed.files).toContain("dist"); + expect(parsed.files).toContain("emdash-plugin.jsonc"); + }); + + it("declares @emdash-cms/plugin-cli as a devDep (provides emdash-plugin binary)", () => { + const parsed = JSON.parse(renderPackageJson(FULL_INPUTS)); + expect(parsed.devDependencies["@emdash-cms/plugin-cli"]).toBeDefined(); }); }); @@ -199,17 +211,23 @@ describe("renderTsconfig", () => { }); describe("renderPluginEntry", () => { - it("imports definePlugin and PluginContext from emdash", () => { + it("type-only-imports SandboxedPlugin from emdash/plugin", () => { const source = renderPluginEntry(); - expect(source).toContain('import { definePlugin } from "emdash"'); - expect(source).toContain('import type { PluginContext } from "emdash"'); + expect(source).toContain('import type { SandboxedPlugin } from "emdash/plugin"'); + // No runtime emdash imports — sandboxed plugins must not pull + // the emdash runtime into their bundle. + expect(source).not.toContain('import { definePlugin } from "emdash"'); }); - it("default-exports a definePlugin call with a hello route", () => { + it("default-exports a bare object with `satisfies SandboxedPlugin` and a hello route", () => { const source = renderPluginEntry(); - expect(source).toContain("export default definePlugin"); + expect(source).toContain("export default {"); + expect(source).toContain("satisfies SandboxedPlugin"); expect(source).toContain("hello:"); expect(source).toContain("greeting:"); + // definePlugin must not appear in the scaffold — it's + // native-only now and would throw at runtime if used here. + expect(source).not.toContain("definePlugin"); }); }); @@ -227,8 +245,8 @@ describe("renderGitignore", () => { expect(renderGitignore()).toContain("node_modules"); }); - it("does not ignore dist — the scaffold has no dist", () => { - expect(renderGitignore()).not.toContain("dist"); + it("ignores dist — the build pipeline writes it but it shouldn't be committed", () => { + expect(renderGitignore()).toContain("dist"); }); }); diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 8255885fc..53ec46559 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -10,7 +10,7 @@ * is the contract reader. * - **`@emdash-cms/plugin-cli`** writes `manifest.json` during bundling * (extracted from the plugin author's source) and publishes the resulting - * records via atproto. registry-cli is the contract writer. + * records via atproto. plugin-cli is the contract writer. * * Anything that has to round-trip cleanly between writer and reader belongs * here: the capability vocabulary, the manifest shape, the hook/route entry diff --git a/packages/plugins/atproto/src/plugin.ts b/packages/plugins/atproto/src/plugin.ts index 43c1c44f1..122a7a6b0 100644 --- a/packages/plugins/atproto/src/plugin.ts +++ b/packages/plugins/atproto/src/plugin.ts @@ -504,7 +504,7 @@ export default { }, admin: { - handler: async (routeCtx: any, ctx: PluginContext) => { + handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as AdminInteraction | undefined; const interactionType = interaction?.type ?? "page_load"; const pageTarget = getAdminPageTarget(interaction); From c5031d5619c7d33234167b4d503c3fd72f4fec6e Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sat, 16 May 2026 06:32:22 +0000 Subject: [PATCH 08/16] style: format --- .../emdash-sandboxed-plugin-authoring.md | 4 +- .changeset/plugin-cli-build-command.md | 8 +- .../core/src/plugins/adapt-sandbox-entry.ts | 26 +--- packages/core/src/plugins/define-plugin.ts | 2 +- .../unit/plugins/adapt-sandbox-entry.test.ts | 2 +- .../schemas/emdash-plugin.schema.json | 66 ++------- packages/plugin-cli/src/build/api.ts | 12 +- packages/plugin-cli/src/build/pipeline.ts | 13 +- packages/plugin-cli/src/bundle/api.ts | 14 +- packages/plugin-cli/src/commands/publish.ts | 10 +- packages/plugin-cli/src/index.ts | 2 +- packages/plugin-cli/src/manifest/translate.ts | 5 +- pnpm-workspace.yaml | 132 +++++++++--------- 13 files changed, 112 insertions(+), 184 deletions(-) diff --git a/.changeset/emdash-sandboxed-plugin-authoring.md b/.changeset/emdash-sandboxed-plugin-authoring.md index 7b1365af6..f8aff39ad 100644 --- a/.changeset/emdash-sandboxed-plugin-authoring.md +++ b/.changeset/emdash-sandboxed-plugin-authoring.md @@ -4,7 +4,7 @@ **BREAKING (plugin authors):** Reworks how sandboxed plugins are defined. The `definePlugin()` helper is removed for sandboxed-format plugins; the new shape is a bare default export with a `satisfies SandboxedPlugin` annotation. A new type-only subpath `emdash/plugin` provides the types. -This affects anyone *writing* a sandboxed plugin. Sites that *use* plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins). +This affects anyone _writing_ a sandboxed plugin. Sites that _use_ plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins). **Before:** @@ -52,7 +52,7 @@ Three changes: 2. **`import type { SandboxedPlugin } from "emdash/plugin"`** and add `satisfies SandboxedPlugin` to the default export. The `emdash/plugin` subpath is type-only — the bundler erases the import, so no runtime resolution of `emdash` is needed (and the heavy `emdash` runtime no longer enters the plugin bundle). 3. **Drop handler parameter annotations** like `event: ContentSaveEvent, ctx: PluginContext`. The strict mapped type on `SandboxedPlugin` infers them per hook name, with the full canonical event type. If you need to reference an event type by name (e.g. in a helper function), `emdash/plugin` re-exports them: `import type { ContentHookEvent, PluginContext } from "emdash/plugin"`. -**Why:** the old `definePlugin` was an identity function whose only job was to alias `emdash` to a Proxy shim at build time so the import would resolve. With the new shape, sandboxed plugins have *no* runtime `emdash` import — only type-only imports from `emdash/plugin`. The bundler doesn't need to alias anything; the build pipeline is simpler; and authors get strict per-hook event/return type inference for free. +**Why:** the old `definePlugin` was an identity function whose only job was to alias `emdash` to a Proxy shim at build time so the import would resolve. With the new shape, sandboxed plugins have _no_ runtime `emdash` import — only type-only imports from `emdash/plugin`. The bundler doesn't need to alias anything; the build pipeline is simpler; and authors get strict per-hook event/return type inference for free. The trade-off: previously you could narrow an event type locally (e.g. `interface ContentSaveEvent { content: ... & { id: string } }`). Under the strict mapped type, the canonical event type wins (TypeScript's contravariance on function parameters means narrowing isn't assignable). Authors validate fields at runtime with `typeof` / `isRecord` checks instead — which is the right pattern for input that comes from outside the type system anyway. diff --git a/.changeset/plugin-cli-build-command.md b/.changeset/plugin-cli-build-command.md index c45998add..5fcfbcad0 100644 --- a/.changeset/plugin-cli-build-command.md +++ b/.changeset/plugin-cli-build-command.md @@ -18,10 +18,10 @@ A typical plugin `package.json`: ```json { - "scripts": { - "build": "emdash-plugin build", - "dev": "emdash-plugin dev" - } + "scripts": { + "build": "emdash-plugin build", + "dev": "emdash-plugin dev" + } } ``` diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index f0546424d..0c952c000 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -57,9 +57,7 @@ const DEFAULT_ERROR_POLICY = "abort" as const; /** * Check if a hook entry is the config form (has a `handler` property). */ -function isHookConfig( - entry: AnyHookEntry, -): entry is Exclude { +function isHookConfig(entry: AnyHookEntry): entry is Exclude { return typeof entry === "object" && entry !== null && "handler" in entry; } @@ -73,10 +71,7 @@ function isHookConfig( * so the handler is compatible as-is — we just normalise the * surrounding config (priority, timeout, etc.) to its defaults. */ -function resolveSandboxedHook( - entry: AnyHookEntry, - pluginId: string, -): ResolvedHook { +function resolveSandboxedHook(entry: AnyHookEntry, pluginId: string): ResolvedHook { if (isHookConfig(entry)) { return { priority: entry.priority ?? DEFAULT_PRIORITY, @@ -156,10 +151,7 @@ export function adaptSandboxEntry( // We store it as the generic type and let HookPipeline's typed dispatch // methods handle the type narrowing at call time. // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- bridging untyped map to typed interface - (resolvedHooks as Record)[hookName] = resolveSandboxedHook( - entry, - pluginId, - ); + (resolvedHooks as Record)[hookName] = resolveSandboxedHook(entry, pluginId); } } @@ -178,12 +170,8 @@ export function adaptSandboxEntry( const handler = isConfig ? (rawEntry as { handler: (...args: unknown[]) => Promise }).handler : (rawEntry as (...args: unknown[]) => Promise); - const publicFlag = isConfig - ? (rawEntry as { public?: boolean }).public - : undefined; - const inputSchema = isConfig - ? (rawEntry as { input?: unknown }).input - : undefined; + const publicFlag = isConfig ? (rawEntry as { public?: boolean }).public : undefined; + const inputSchema = isConfig ? (rawEntry as { input?: unknown }).input : undefined; resolvedRoutes[routeName] = { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- route entry.input is intentionally loosely typed; callers validate at runtime input: inputSchema as PluginRoute["input"], @@ -204,9 +192,7 @@ export function adaptSandboxEntry( headers[name] = value; }); } else { - for (const [name, value] of Object.entries( - h as Record, - )) { + for (const [name, value] of Object.entries(h as Record)) { headers[name] = value; } } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 98e9682e3..d5bf14ac9 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -81,7 +81,7 @@ export function definePlugin( `definePlugin() requires \`id\` (got ${typeof definition.id}). ` + "For native plugins, make sure your definition has both `id` and " + "`version`. For sandboxed plugins, drop `definePlugin()` entirely " + - 'and `export default { hooks, routes } satisfies SandboxedPlugin` ' + + "and `export default { hooks, routes } satisfies SandboxedPlugin` " + 'from "emdash/plugin" — identity comes from `emdash-plugin.jsonc`.', ); } diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index 7d93a4160..4e393ea2b 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -9,8 +9,8 @@ import { describe, it, expect, vi } from "vitest"; import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js"; -import { adaptSandboxEntry } from "../../../src/plugins/adapt-sandbox-entry.js"; import type { SandboxedPlugin } from "../../../src/plugin-types.js"; +import { adaptSandboxEntry } from "../../../src/plugins/adapt-sandbox-entry.js"; /** * Create a mock hook handler with a loose signature. The strict diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json index 690e5a0d0..a7b130a65 100644 --- a/packages/plugin-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -57,14 +57,7 @@ "$ref": "#/$defs/__schema42" } }, - "required": [ - "slug", - "license", - "publisher", - "capabilities", - "allowedHosts", - "storage" - ], + "required": ["slug", "license", "publisher", "capabilities", "allowedHosts", "storage"], "additionalProperties": false, "$defs": { "__schema0": { @@ -78,11 +71,7 @@ "pattern": "^[a-z][a-z0-9_-]*$", "title": "Slug", "description": "URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.", - "examples": [ - "gallery", - "image-resizer", - "my-plugin" - ] + "examples": ["gallery", "image-resizer", "my-plugin"] }, "__schema2": { "type": "string", @@ -91,11 +80,7 @@ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", "title": "Version", "description": "Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.", - "examples": [ - "0.1.0", - "1.2.3", - "1.0.0-rc.1" - ] + "examples": ["0.1.0", "1.2.3", "1.0.0-rc.1"] }, "__schema3": { "type": "string", @@ -103,20 +88,13 @@ "maxLength": 256, "title": "License", "description": "SPDX license expression (e.g. \"MIT\", \"Apache-2.0\", \"MIT OR Apache-2.0\"). Required on first publish; ignored on subsequent publishes (the existing profile wins).", - "examples": [ - "MIT", - "Apache-2.0", - "MIT OR Apache-2.0" - ] + "examples": ["MIT", "Apache-2.0", "MIT OR Apache-2.0"] }, "__schema4": { "type": "string", "title": "Publisher", "description": "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", - "examples": [ - "did:plc:abc123def456", - "example.com" - ] + "examples": ["did:plc:abc123def456", "example.com"] }, "__schema5": { "default": [], @@ -141,12 +119,7 @@ }, "title": "Allowed hosts", "description": "Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.", - "examples": [ - [ - "api.example.com", - "*.cdn.example.com" - ] - ] + "examples": [["api.example.com", "*.cdn.example.com"]] }, "__schema8": { "type": "string", @@ -180,9 +153,7 @@ "$ref": "#/$defs/__schema14" } }, - "required": [ - "indexes" - ], + "required": ["indexes"], "additionalProperties": false, "title": "Storage collection", "description": "Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names)." @@ -265,10 +236,7 @@ "$ref": "#/$defs/__schema21" } }, - "required": [ - "path", - "label" - ], + "required": ["path", "label"], "additionalProperties": false, "title": "Admin page", "description": "A single admin page declaration. The plugin's `admin` route handler is responsible for rendering Block Kit content for this path." @@ -309,9 +277,7 @@ "$ref": "#/$defs/__schema26" } }, - "required": [ - "id" - ], + "required": ["id"], "additionalProperties": false, "title": "Admin widget", "description": "A single dashboard widget declaration." @@ -329,11 +295,7 @@ }, "__schema26": { "type": "string", - "enum": [ - "full", - "half", - "third" - ] + "enum": ["full", "half", "third"] }, "__schema27": { "$ref": "#/$defs/__schema28" @@ -351,9 +313,7 @@ "$ref": "#/$defs/__schema31" } }, - "required": [ - "name" - ], + "required": ["name"], "additionalProperties": false, "title": "Author", "description": "A single author entry. Mirrors the lexicon's author shape." @@ -462,9 +422,7 @@ "pattern": "^https:\\/\\/", "title": "Source repository", "description": "HTTPS URL of the plugin's source repository. Surfaced in registry listings.", - "examples": [ - "https://github.com/emdash-cms/plugin-gallery" - ] + "examples": ["https://github.com/emdash-cms/plugin-gallery"] } } } diff --git a/packages/plugin-cli/src/build/api.ts b/packages/plugin-cli/src/build/api.ts index b6bf204d3..480cbce6b 100644 --- a/packages/plugin-cli/src/build/api.ts +++ b/packages/plugin-cli/src/build/api.ts @@ -37,6 +37,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import type { PluginManifest, ResolvedPlugin } from "../bundle/types.js"; +import { extractManifest } from "../bundle/utils.js"; +import type { NormalisedManifest } from "../manifest/translate.js"; import { buildRuntime, probeAndAssemble, @@ -46,9 +49,6 @@ import { type PipelineLogger, type ResolvedSources, } from "./pipeline.js"; -import { extractManifest } from "../bundle/utils.js"; -import type { PluginManifest, ResolvedPlugin } from "../bundle/types.js"; -import type { NormalisedManifest } from "../manifest/translate.js"; // ────────────────────────────────────────────────────────────────────────── // Public types @@ -179,11 +179,7 @@ export async function buildPlugin(options: BuildOptions): Promise { // ── 3. Write dist/manifest.json (wire shape) ── const manifestJson = join(outDir, "manifest.json"); - await writeFile( - manifestJson, - `${JSON.stringify(wireManifest, null, 2)}\n`, - "utf-8", - ); + await writeFile(manifestJson, `${JSON.stringify(wireManifest, null, 2)}\n`, "utf-8"); log.success?.("Wrote manifest.json"); // ── 4. Generate dist/index.mjs (+ .d.mts) — descriptor module ── diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts index 2611a34b3..b4e37725b 100644 --- a/packages/plugin-cli/src/build/pipeline.ts +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -37,6 +37,8 @@ import { copyFile, mkdir, readFile } from "node:fs/promises"; import { join, resolve } from "node:path"; +import type { ResolvedPlugin } from "../bundle/types.js"; +import { fileExists } from "../bundle/utils.js"; import { ManifestError, MANIFEST_FILENAME, @@ -48,8 +50,6 @@ import { VersionMismatchError, type NormalisedManifest, } from "../manifest/translate.js"; -import type { ResolvedPlugin } from "../bundle/types.js"; -import { fileExists } from "../bundle/utils.js"; const PLUGIN_ENTRY_PATH = "src/plugin.ts"; const PACKAGE_JSON_PATH = "package.json"; @@ -192,10 +192,7 @@ async function readPackageMeta(packageJsonPath: string): Promise { try { parsed = JSON.parse(source); } catch { - throw new BuildPipelineError( - "PACKAGE_JSON_INVALID", - `${packageJsonPath} is not valid JSON.`, - ); + throw new BuildPipelineError("PACKAGE_JSON_INVALID", `${packageJsonPath} is not valid JSON.`); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new BuildPipelineError( @@ -349,7 +346,9 @@ export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise { } } } - diff --git a/packages/plugin-cli/src/commands/publish.ts b/packages/plugin-cli/src/commands/publish.ts index 68b38d7f6..d7de19aed 100644 --- a/packages/plugin-cli/src/commands/publish.ts +++ b/packages/plugin-cli/src/commands/publish.ts @@ -339,9 +339,7 @@ async function runPublish(args: PublishArgs): Promise { consola.info( `The aggregator will pick this up from the firehose. To verify discovery once it's indexed:`, ); - console.log( - ` ${pc.cyan(`emdash-plugin info ${session.handle ?? session.did} ${result.slug}`)}`, - ); + console.log(` ${pc.cyan(`emdash-plugin info ${session.handle ?? session.did} ${result.slug}`)}`); } /** @@ -508,7 +506,11 @@ async function readSiblingPackageVersion(manifestDir: string): Promise Date: Sat, 16 May 2026 07:56:50 +0100 Subject: [PATCH 09/16] docs(changesets): switch plugin migration examples to diff fences --- .../emdash-sandboxed-plugin-authoring.md | 67 +++++++------------ .changeset/plugin-atproto-default-export.md | 38 ++++------- .changeset/plugin-audit-log-default-export.md | 38 ++++------- .../plugin-webhook-notifier-default-export.md | 38 ++++------- 4 files changed, 59 insertions(+), 122 deletions(-) diff --git a/.changeset/emdash-sandboxed-plugin-authoring.md b/.changeset/emdash-sandboxed-plugin-authoring.md index f8aff39ad..c93148c12 100644 --- a/.changeset/emdash-sandboxed-plugin-authoring.md +++ b/.changeset/emdash-sandboxed-plugin-authoring.md @@ -6,44 +6,23 @@ This affects anyone _writing_ a sandboxed plugin. Sites that _use_ plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins). -**Before:** - -```ts -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -interface ContentSaveEvent { - content: Record; - collection: string; - isNew: boolean; -} - -export default definePlugin({ - hooks: { - "content:beforeSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { - // ... - return event.content; - }, - }, - }, -}); -``` - -**After:** - -```ts -import type { SandboxedPlugin } from "emdash/plugin"; - -export default { - hooks: { - "content:beforeSave": async (event, ctx) => { - // event: ContentHookEvent, ctx: PluginContext — both inferred. - // ... - return event.content; - }, - }, -} satisfies SandboxedPlugin; +```diff ++ import type { SandboxedPlugin } from "emdash/plugin"; +- import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash"; + +- export default definePlugin({ ++ export default { + hooks: { + "content:beforeSave": { +- handler: async (event: ContentHookEvent, ctx: PluginContext) => { ++ handler: async (event, ctx) => { + // ... + return event.content; + }, + }, + }, +- }); ++ } satisfies SandboxedPlugin; ``` Three changes: @@ -60,12 +39,12 @@ The trade-off: previously you could narrow an event type locally (e.g. `interfac ```ts export default { - routes: { - health: async (routeCtx, ctx) => { - // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. - return new Response("ok"); - }, - }, + routes: { + health: async (routeCtx, ctx) => { + // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. + return new Response("ok"); + }, + }, } satisfies SandboxedPlugin; ``` diff --git a/.changeset/plugin-atproto-default-export.md b/.changeset/plugin-atproto-default-export.md index b4c19d93c..f03f092bc 100644 --- a/.changeset/plugin-atproto-default-export.md +++ b/.changeset/plugin-atproto-default-export.md @@ -4,32 +4,18 @@ **BREAKING:** Removes the `atprotoPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. -**Before:** - -```ts -import { atprotoPlugin } from "@emdash-cms/plugin-atproto"; - -export default defineConfig({ - integrations: [ - emdash({ - sandboxed: [atprotoPlugin()], - }), - ], -}); -``` - -**After:** - -```ts -import atproto from "@emdash-cms/plugin-atproto"; - -export default defineConfig({ - integrations: [ - emdash({ - sandboxed: [atproto], - }), - ], -}); +```diff +- import { atprotoPlugin } from "@emdash-cms/plugin-atproto"; ++ import atproto from "@emdash-cms/plugin-atproto"; + + export default defineConfig({ + integrations: [ + emdash({ +- sandboxed: [atprotoPlugin()], ++ sandboxed: [atproto], + }), + ], + }); ``` Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.changeset/plugin-audit-log-default-export.md b/.changeset/plugin-audit-log-default-export.md index 5165667ad..d7c8d1c2a 100644 --- a/.changeset/plugin-audit-log-default-export.md +++ b/.changeset/plugin-audit-log-default-export.md @@ -4,32 +4,18 @@ **BREAKING:** Removes the `auditLogPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. -**Before:** - -```ts -import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; - -export default defineConfig({ - integrations: [ - emdash({ - plugins: [auditLogPlugin()], - }), - ], -}); -``` - -**After:** - -```ts -import auditLog from "@emdash-cms/plugin-audit-log"; - -export default defineConfig({ - integrations: [ - emdash({ - plugins: [auditLog], - }), - ], -}); +```diff +- import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"; ++ import auditLog from "@emdash-cms/plugin-audit-log"; + + export default defineConfig({ + integrations: [ + emdash({ +- plugins: [auditLogPlugin()], ++ plugins: [auditLog], + }), + ], + }); ``` Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. diff --git a/.changeset/plugin-webhook-notifier-default-export.md b/.changeset/plugin-webhook-notifier-default-export.md index 8881c6c47..4733527c2 100644 --- a/.changeset/plugin-webhook-notifier-default-export.md +++ b/.changeset/plugin-webhook-notifier-default-export.md @@ -4,32 +4,18 @@ **BREAKING:** Removes the `webhookNotifierPlugin` named export and the factory call shape. Import the default export and pass it directly into `plugins:` or `sandboxed:`. -**Before:** - -```ts -import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; - -export default defineConfig({ - integrations: [ - emdash({ - sandboxed: [webhookNotifierPlugin()], - }), - ], -}); -``` - -**After:** - -```ts -import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; - -export default defineConfig({ - integrations: [ - emdash({ - sandboxed: [webhookNotifier], - }), - ], -}); +```diff +- import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; ++ import webhookNotifier from "@emdash-cms/plugin-webhook-notifier"; + + export default defineConfig({ + integrations: [ + emdash({ +- sandboxed: [webhookNotifierPlugin()], ++ sandboxed: [webhookNotifier], + }), + ], + }); ``` Two changes: drop the `{ }` around the import, and drop the `()` after the plugin name. Per-install configuration moved to the admin UI's settings (KV-backed) when the sandboxed plugin redesign landed, so there's no longer a need for a factory call. From fb64cfcaa8646a1acc3c4c97357d1147817d5363 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sat, 16 May 2026 06:58:28 +0000 Subject: [PATCH 10/16] style: format --- .changeset/emdash-sandboxed-plugin-authoring.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/emdash-sandboxed-plugin-authoring.md b/.changeset/emdash-sandboxed-plugin-authoring.md index c93148c12..f2acb27a6 100644 --- a/.changeset/emdash-sandboxed-plugin-authoring.md +++ b/.changeset/emdash-sandboxed-plugin-authoring.md @@ -39,12 +39,12 @@ The trade-off: previously you could narrow an event type locally (e.g. `interfac ```ts export default { - routes: { - health: async (routeCtx, ctx) => { - // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. - return new Response("ok"); - }, - }, + routes: { + health: async (routeCtx, ctx) => { + // routeCtx: SandboxedRouteContext, ctx: PluginContext — both inferred. + return new Response("ok"); + }, + }, } satisfies SandboxedPlugin; ``` From c2bb31972211ce4141d0e70cd314a713391fdc25 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 16 May 2026 07:59:56 +0100 Subject: [PATCH 11/16] Fix changeset ordering --- .changeset/emdash-sandboxed-plugin-authoring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/emdash-sandboxed-plugin-authoring.md b/.changeset/emdash-sandboxed-plugin-authoring.md index f2acb27a6..71aa5788c 100644 --- a/.changeset/emdash-sandboxed-plugin-authoring.md +++ b/.changeset/emdash-sandboxed-plugin-authoring.md @@ -7,8 +7,8 @@ This affects anyone _writing_ a sandboxed plugin. Sites that _use_ plugins are unaffected (see the per-plugin changesets for the import-shape change in published plugins). ```diff -+ import type { SandboxedPlugin } from "emdash/plugin"; - import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash"; ++ import type { SandboxedPlugin } from "emdash/plugin"; - export default definePlugin({ + export default { From 5d810248ce5382cc5ba49443e45d5cf0d4c00ffb Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 16 May 2026 08:39:25 +0100 Subject: [PATCH 12/16] fix(ci): plugin build uses node-direct path; sweep stale registry-cli refs In-workspace plugins use `node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build` because pnpm doesn't create the bin shim for a workspace package whose bin target doesn't exist at install time. Plugin authors outside the workspace get a published bin with a real dist, so `emdash-plugin build` works for them via the natural scaffold. Also fixes stale registry-cli references the rename pass missed: - .oxfmtrc.json: schema ignore path - .oxlintrc.json: 7 type-aware-cost allowlist entries - .github/workflows/ci.yml: build filter includes plugin-cli for test:unit - package.json: test:unit script - packages/plugin-types/package.json: description The schema file is regenerated to match what gen-schema produces. The previously committed version had been hand-reformatted post-regen and disagreed with the generator's output. --- .github/workflows/ci.yml | 6 +- .oxfmtrc.json | 2 +- .oxlintrc.json | 14 ++-- package.json | 2 +- .../schemas/emdash-plugin.schema.json | 66 +++++++++++++++---- packages/plugin-cli/scripts/gen-schema.ts | 2 +- packages/plugin-types/package.json | 2 +- packages/plugins/atproto/package.json | 2 +- packages/plugins/audit-log/package.json | 2 +- .../plugins/marketplace-test/package.json | 2 +- packages/plugins/sandboxed-test/package.json | 2 +- .../plugins/webhook-notifier/package.json | 2 +- 12 files changed, 73 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a401f5f2..58283dc7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,11 +90,11 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - # Build emdash + its deps AND the registry packages. The registry - # packages aren't deps of `emdash`, so the `emdash...` filter would + # Build emdash + its deps AND the plugin-cli + registry packages. + # They aren't deps of `emdash`, so the `emdash...` filter would # leave them unbuilt and their tests would fail to resolve workspace # links to dist/. - - run: pnpm run --filter emdash... --filter "@emdash-cms/registry-*" --filter "@emdash-cms/plugin-types" build + - run: pnpm run --filter emdash... --filter "@emdash-cms/plugin-cli" --filter "@emdash-cms/registry-*" --filter "@emdash-cms/plugin-types" build - run: pnpm test:unit env: EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b45920654..8beb4ae23 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,6 +8,6 @@ "**/package.json", "**/emdash-env.d.ts", "packages/registry-lexicons/src/generated/**", - "packages/registry-cli/schemas/**" + "packages/plugin-cli/schemas/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index da3b5f8f1..1473bf237 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -68,13 +68,13 @@ "**/client/transport.ts", "**/client/portable-text.ts", "**/cli/**/*.ts", - "packages/registry-cli/src/bundle/api.ts", - "packages/registry-cli/src/bundle/utils.ts", - "packages/registry-cli/src/bundle/command.ts", - "packages/registry-cli/src/bundle/types.ts", - "packages/registry-cli/src/oauth.ts", - "packages/registry-cli/src/publish/api.ts", - "packages/registry-cli/src/commands/publish.ts", + "packages/plugin-cli/src/bundle/api.ts", + "packages/plugin-cli/src/bundle/utils.ts", + "packages/plugin-cli/src/bundle/command.ts", + "packages/plugin-cli/src/bundle/types.ts", + "packages/plugin-cli/src/oauth.ts", + "packages/plugin-cli/src/publish/api.ts", + "packages/plugin-cli/src/commands/publish.ts", "packages/registry-client/src/publishing/index.ts", "**/api/handlers/api-tokens.ts", "**/api/handlers/device-flow.ts", diff --git a/package.json b/package.json index 99470d6ef..d0ec12531 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck:templates": "pnpm run --workspace-concurrency=1 --filter {./templates/*} typecheck", "check": "pnpm run typecheck && pnpm run --filter {./packages/*} check", "test": "pnpm run --filter {./packages/*} test", - "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-cli --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", + "test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test", "test:browser": "pnpm run --filter @emdash-cms/admin test", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json index a7b130a65..690e5a0d0 100644 --- a/packages/plugin-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -57,7 +57,14 @@ "$ref": "#/$defs/__schema42" } }, - "required": ["slug", "license", "publisher", "capabilities", "allowedHosts", "storage"], + "required": [ + "slug", + "license", + "publisher", + "capabilities", + "allowedHosts", + "storage" + ], "additionalProperties": false, "$defs": { "__schema0": { @@ -71,7 +78,11 @@ "pattern": "^[a-z][a-z0-9_-]*$", "title": "Slug", "description": "URL-safe plugin identifier within the publisher's namespace. ASCII letter then letters/digits/hyphens/underscores, max 64 characters. Combined with the publisher DID, this is the registry's primary key.", - "examples": ["gallery", "image-resizer", "my-plugin"] + "examples": [ + "gallery", + "image-resizer", + "my-plugin" + ] }, "__schema2": { "type": "string", @@ -80,7 +91,11 @@ "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", "title": "Version", "description": "Plugin version. Semver 2.0 subset; build-metadata `+...` is disallowed (the atproto record-key alphabet has no `+`). Bumped on every release.", - "examples": ["0.1.0", "1.2.3", "1.0.0-rc.1"] + "examples": [ + "0.1.0", + "1.2.3", + "1.0.0-rc.1" + ] }, "__schema3": { "type": "string", @@ -88,13 +103,20 @@ "maxLength": 256, "title": "License", "description": "SPDX license expression (e.g. \"MIT\", \"Apache-2.0\", \"MIT OR Apache-2.0\"). Required on first publish; ignored on subsequent publishes (the existing profile wins).", - "examples": ["MIT", "Apache-2.0", "MIT OR Apache-2.0"] + "examples": [ + "MIT", + "Apache-2.0", + "MIT OR Apache-2.0" + ] }, "__schema4": { "type": "string", "title": "Publisher", "description": "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.", - "examples": ["did:plc:abc123def456", "example.com"] + "examples": [ + "did:plc:abc123def456", + "example.com" + ] }, "__schema5": { "default": [], @@ -119,7 +141,12 @@ }, "title": "Allowed hosts", "description": "Allow-list of outbound host patterns when `network:request` is declared. Subdomain wildcards use a leading `*.`. Required (non-empty) when `network:request` is declared without `network:request:unrestricted`.", - "examples": [["api.example.com", "*.cdn.example.com"]] + "examples": [ + [ + "api.example.com", + "*.cdn.example.com" + ] + ] }, "__schema8": { "type": "string", @@ -153,7 +180,9 @@ "$ref": "#/$defs/__schema14" } }, - "required": ["indexes"], + "required": [ + "indexes" + ], "additionalProperties": false, "title": "Storage collection", "description": "Index configuration for a single storage collection. Indexes are either single field names or composite (array of field names)." @@ -236,7 +265,10 @@ "$ref": "#/$defs/__schema21" } }, - "required": ["path", "label"], + "required": [ + "path", + "label" + ], "additionalProperties": false, "title": "Admin page", "description": "A single admin page declaration. The plugin's `admin` route handler is responsible for rendering Block Kit content for this path." @@ -277,7 +309,9 @@ "$ref": "#/$defs/__schema26" } }, - "required": ["id"], + "required": [ + "id" + ], "additionalProperties": false, "title": "Admin widget", "description": "A single dashboard widget declaration." @@ -295,7 +329,11 @@ }, "__schema26": { "type": "string", - "enum": ["full", "half", "third"] + "enum": [ + "full", + "half", + "third" + ] }, "__schema27": { "$ref": "#/$defs/__schema28" @@ -313,7 +351,9 @@ "$ref": "#/$defs/__schema31" } }, - "required": ["name"], + "required": [ + "name" + ], "additionalProperties": false, "title": "Author", "description": "A single author entry. Mirrors the lexicon's author shape." @@ -422,7 +462,9 @@ "pattern": "^https:\\/\\/", "title": "Source repository", "description": "HTTPS URL of the plugin's source repository. Surfaced in registry listings.", - "examples": ["https://github.com/emdash-cms/plugin-gallery"] + "examples": [ + "https://github.com/emdash-cms/plugin-gallery" + ] } } } diff --git a/packages/plugin-cli/scripts/gen-schema.ts b/packages/plugin-cli/scripts/gen-schema.ts index 6583b2784..798a10b7f 100644 --- a/packages/plugin-cli/scripts/gen-schema.ts +++ b/packages/plugin-cli/scripts/gen-schema.ts @@ -6,7 +6,7 @@ * to `schemas/emdash-plugin.schema.json` and shipped in the package's * `files` array so users can reference it via: * - * "$schema": "./node_modules/@emdash-cms/registry-cli/schemas/emdash-plugin.schema.json" + * "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json" * * Drift between the Zod schema and the committed JSON Schema is caught * by the snapshot test in `tests/schema.test.ts`. diff --git a/packages/plugin-types/package.json b/packages/plugin-types/package.json index 131f0c306..ac4572d1f 100644 --- a/packages/plugin-types/package.json +++ b/packages/plugin-types/package.json @@ -1,7 +1,7 @@ { "name": "@emdash-cms/plugin-types", "version": "0.0.1", - "description": "Shared TypeScript types for the EmDash plugin manifest contract: capability vocabulary, manifest shape, hook/route entry types. Consumed by core (manifest reader at install/runtime) and registry-cli (manifest writer at bundle/publish time).", + "description": "Shared TypeScript types for the EmDash plugin manifest contract: capability vocabulary, manifest shape, hook/route entry types. Consumed by core (manifest reader at install/runtime) and plugin-cli (manifest writer at bundle/publish time).", "type": "module", "main": "dist/index.js", "exports": { diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index 5f831b72a..7bc8dfc16 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -35,7 +35,7 @@ "vitest": "catalog:" }, "scripts": { - "build": "emdash-plugin build", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", "dev": "emdash-plugin dev", "test": "vitest run", "typecheck": "tsgo --noEmit" diff --git a/packages/plugins/audit-log/package.json b/packages/plugins/audit-log/package.json index 5561811cd..95008b300 100644 --- a/packages/plugins/audit-log/package.json +++ b/packages/plugins/audit-log/package.json @@ -24,7 +24,7 @@ "typescript": "catalog:" }, "scripts": { - "build": "emdash-plugin build", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, diff --git a/packages/plugins/marketplace-test/package.json b/packages/plugins/marketplace-test/package.json index 5cff78d9a..5c7c964d8 100644 --- a/packages/plugins/marketplace-test/package.json +++ b/packages/plugins/marketplace-test/package.json @@ -14,7 +14,7 @@ }, "files": ["dist", "emdash-plugin.jsonc"], "scripts": { - "build": "emdash-plugin build", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, diff --git a/packages/plugins/sandboxed-test/package.json b/packages/plugins/sandboxed-test/package.json index 6d655ec8e..1a0683f77 100644 --- a/packages/plugins/sandboxed-test/package.json +++ b/packages/plugins/sandboxed-test/package.json @@ -14,7 +14,7 @@ }, "files": ["dist", "emdash-plugin.jsonc"], "scripts": { - "build": "emdash-plugin build", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, diff --git a/packages/plugins/webhook-notifier/package.json b/packages/plugins/webhook-notifier/package.json index c62f99668..377abd0f2 100644 --- a/packages/plugins/webhook-notifier/package.json +++ b/packages/plugins/webhook-notifier/package.json @@ -24,7 +24,7 @@ "typescript": "catalog:" }, "scripts": { - "build": "emdash-plugin build", + "build": "node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build", "dev": "emdash-plugin dev", "typecheck": "tsgo --noEmit" }, From 40d4d5765f5110c38984b7348ae5330101c09a5c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 16 May 2026 09:03:16 +0100 Subject: [PATCH 13/16] fix(ci): remove legacy marketplace bundle path; address review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete `packages/marketplace/tests/publish-e2e.test.ts` — invoked the legacy `emdash plugin bundle` from core CLI against the new manifest-driven plugin layout, which it doesn't understand. - Remove the validate-plugins CI job — it used the same legacy CLI command. Plugin validation is now covered by `pnpm build`, which runs the new `emdash-plugin build` probe + manifest checks against every in-tree sandboxed plugin. - Fix `no-base-to-string` lint errors in audit-log/plugin.ts. The canonical ContentHookEvent types `event.content.id` as unknown; `String(unknown)` lands on '[object Object]' for record IDs. Added a small `stringifyId` helper that returns '' for non-string/number inputs so the caller's existence check skips bad rows. - pipeline.ts now hard-errors when the probed module has no `default` export, instead of silently falling through to an empty plugin (build had been writing dist/ artifacts with empty hooks/routes for any source that used `export const plugin = ...`). - Scaffold README camelCases hyphenated slugs for the import binding. Slugs like `my-plugin` were producing `import my-plugin from ...` which is a syntax error. Test added with a hyphenated fixture. Both bot review comments addressed. --- .github/workflows/ci.yml | 33 -- .../marketplace/tests/publish-e2e.test.ts | 324 ------------------ packages/plugin-cli/src/build/pipeline.ts | 8 +- packages/plugin-cli/src/init/templates.ts | 21 +- .../plugin-cli/tests/init-templates.test.ts | 9 + packages/plugins/audit-log/src/plugin.ts | 20 +- 6 files changed, 50 insertions(+), 365 deletions(-) delete mode 100644 packages/marketplace/tests/publish-e2e.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58283dc7a..6ed34e1fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,39 +99,6 @@ jobs: env: EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test - validate-plugins: - name: Validate Plugins - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run --filter emdash... build - - name: Validate marketplace plugins - run: | - CLI="node packages/core/dist/cli/index.mjs" - for dir in packages/plugins/*/; do - [ -f "$dir/package.json" ] || continue - if [ ! -f "$dir/src/sandbox-entry.ts" ] && \ - ! grep -q '"./sandbox"' "$dir/package.json" 2>/dev/null; then - continue - fi - name=$(basename "$dir") - case "$name" in - marketplace-test|sandboxed-test|api-test) continue ;; - esac - echo "::group::Validating $name" - $CLI plugin bundle --validateOnly --dir "$dir" - echo "::endgroup::" - done - test-smoke: name: Smoke Tests runs-on: ubuntu-latest diff --git a/packages/marketplace/tests/publish-e2e.test.ts b/packages/marketplace/tests/publish-e2e.test.ts deleted file mode 100644 index 3dae032e4..000000000 --- a/packages/marketplace/tests/publish-e2e.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * E2E tests for plugin publishing flow. - * - * Runs the real Hono app with: - * - better-sqlite3 as a D1 mock - * - In-memory Map as R2 mock - * - Seed token auth (skips audit, publishes immediately) - * - * Tests the full path: tarball upload -> manifest validation -> DB write -> R2 store -> public API listing - */ - -import { execSync } from "node:child_process"; -import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve, join } from "node:path"; - -import Database from "better-sqlite3"; -import { describe, it, expect, beforeAll, beforeEach } from "vitest"; - -// Polyfill crypto.subtle.timingSafeEqual (Workers API not in Node) -const subtle = crypto.subtle as unknown as Record; -if (!subtle.timingSafeEqual) { - subtle.timingSafeEqual = (a: ArrayBuffer, b: ArrayBuffer): boolean => { - return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b)); - }; -} - -import app from "../src/app.js"; - -// ── D1 mock using better-sqlite3 ────────────────────────────── - -function createD1Mock() { - const db = new Database(":memory:"); - const schemaPath = resolve(import.meta.dirname, "../src/db/schema.sql"); - const schema = readFileSync(schemaPath, "utf-8"); - db.exec(schema); - - return { - _db: db, - prepare(query: string) { - return { - _query: query, - _bindings: [] as unknown[], - bind(...args: unknown[]) { - this._bindings = args; - return this; - }, - async first(column?: string): Promise { - const stmt = db.prepare(this._query); - const row = stmt.get(...this._bindings) as Record | undefined; - if (!row) return null; - if (column) return (row[column] ?? null) as T; - return row as T; - }, - async all(): Promise<{ results: T[] }> { - const stmt = db.prepare(this._query); - const rows = stmt.all(...this._bindings) as T[]; - return { results: rows }; - }, - async run() { - const stmt = db.prepare(this._query); - const result = stmt.run(...this._bindings); - return { - success: true, - meta: { changes: result.changes, last_row_id: result.lastInsertRowid }, - }; - }, - }; - }, - async batch(statements: { _query: string; _bindings: unknown[] }[]) { - const results = []; - for (const stmt of statements) { - const s = db.prepare(stmt._query); - results.push(s.run(...stmt._bindings)); - } - return results; - }, - }; -} - -// ── R2 mock ──────────────────────────────────────────────────── - -function createR2Mock() { - const store = new Map }>(); - return { - async put( - key: string, - data: ArrayBuffer | Uint8Array | ReadableStream, - opts?: { httpMetadata?: { contentType?: string } }, - ) { - let buf: ArrayBuffer; - if (data instanceof ArrayBuffer) { - buf = data; - } else if (ArrayBuffer.isView(data)) { - buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; - } else { - const reader = (data as ReadableStream).getReader(); - const chunks: Uint8Array[] = []; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value) chunks.push(value); - } - const total = chunks.reduce((acc, c) => acc + c.length, 0); - const merged = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; - } - buf = merged.buffer as ArrayBuffer; - } - store.set(key, { data: buf, metadata: opts?.httpMetadata }); - }, - async get(key: string) { - const entry = store.get(key); - if (!entry) return null; - return { - arrayBuffer: async () => entry.data, - body: new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(entry.data)); - controller.close(); - }, - }), - }; - }, - async head(key: string) { - return store.has(key) ? { size: store.get(key)!.data.byteLength } : null; - }, - _store: store, - }; -} - -// ── Test fixtures ────────────────────────────────────────────── - -const RE_EXTRACT_OR_TARBALL = /extract|tarball/i; -const SEED_TOKEN = "test-seed-token-for-e2e"; -const REPO_ROOT = resolve(import.meta.dirname, "../../.."); - -let auditLogTarball: Buffer; -let auditLogVersion: string; - -beforeAll(async () => { - // Build the audit-log plugin tarball - execSync("node packages/core/dist/cli/index.mjs plugin bundle --dir packages/plugins/audit-log", { - cwd: REPO_ROOT, - stdio: "pipe", - }); - - const auditLogPkg = JSON.parse( - readFileSync(join(REPO_ROOT, "packages/plugins/audit-log/package.json"), "utf-8"), - ) as { version: string }; - auditLogVersion = auditLogPkg.version; - - const distDir = join(REPO_ROOT, "packages/plugins/audit-log/dist"); - const tarballName = `audit-log-${auditLogVersion}.tar.gz`; - auditLogTarball = await readFile(join(distDir, tarballName)); -}, 30000); - -// ── Tests ────────────────────────────────────────────────────── - -describe("marketplace publish e2e", () => { - let env: Record; - - beforeEach(() => { - env = { - DB: createD1Mock(), - R2: createR2Mock(), - SEED_TOKEN, - GITHUB_CLIENT_ID: "test", - GITHUB_CLIENT_SECRET: "test-secret", - AUDIT_ENFORCEMENT: "none", - }; - }); - - it("publishes a plugin tarball via seed auth and lists it", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - - const publishRes = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: formData, - }, - env, - ); - - expect(publishRes.status).toBe(201); - const publishBody = (await publishRes.json()) as Record; - expect(publishBody.version).toBe(auditLogVersion); - expect(publishBody.status).toBe("published"); - expect(publishBody.checksum).toBeTruthy(); - - // Verify the plugin is listed - const listRes = await app.request("/api/v1/plugins", {}, env); - expect(listRes.status).toBe(200); - const listBody = (await listRes.json()) as { items: { id: string }[] }; - expect(listBody.items).toHaveLength(1); - expect(listBody.items[0]!.id).toBe("audit-log"); - - // Verify the specific plugin endpoint - const detailRes = await app.request("/api/v1/plugins/audit-log", {}, env); - expect(detailRes.status).toBe(200); - const detailBody = (await detailRes.json()) as { id: string }; - expect(detailBody.id).toBe("audit-log"); - - // Verify the version endpoint - const versionRes = await app.request("/api/v1/plugins/audit-log/versions", {}, env); - expect(versionRes.status).toBe(200); - const versionBody = (await versionRes.json()) as { - items: { version: string; status: string }[]; - }; - expect(versionBody.items).toHaveLength(1); - expect(versionBody.items[0]!.version).toBe(auditLogVersion); - expect(versionBody.items[0]!.status).toBe("published"); - }); - - it("re-publishes same version idempotently via seed auth", async () => { - const makeFormData = () => { - const fd = new FormData(); - fd.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - return fd; - }; - - // First publish - const res1 = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: makeFormData(), - }, - env, - ); - expect(res1.status).toBe(201); - - // Re-publish same version - const res2 = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: makeFormData(), - }, - env, - ); - expect(res2.status).toBe(201); - - // Still only one version - const versionRes = await app.request("/api/v1/plugins/audit-log/versions", {}, env); - const body = (await versionRes.json()) as { items: unknown[] }; - expect(body.items).toHaveLength(1); - }); - - it("rejects publish without auth", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - - const res = await app.request( - "/api/v1/plugins/audit-log/versions", - { method: "POST", body: formData }, - env, - ); - expect(res.status).toBe(401); - }); - - it("rejects invalid tarball", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([new Uint8Array([1, 2, 3])], { type: "application/gzip" }), - "bad.tar.gz", - ); - - const res = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: `Bearer ${SEED_TOKEN}` }, - body: formData, - }, - env, - ); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toMatch(RE_EXTRACT_OR_TARBALL); - }); - - it("rejects wrong seed token", async () => { - const formData = new FormData(); - formData.append( - "bundle", - new Blob([auditLogTarball], { type: "application/gzip" }), - `audit-log-${auditLogVersion}.tar.gz`, - ); - - const res = await app.request( - "/api/v1/plugins/audit-log/versions", - { - method: "POST", - headers: { Authorization: "Bearer wrong-token" }, - body: formData, - }, - env, - ); - expect(res.status).toBe(401); - }); -}); diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts index b4e37725b..f94bd3588 100644 --- a/packages/plugin-cli/src/build/pipeline.ts +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -296,7 +296,13 @@ export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise; - const definition = (pluginModule.default ?? {}) as Record; + if (pluginModule.default === undefined) { + throw new BuildPipelineError( + "INVALID_PLUGIN_FORMAT", + `${entries.pluginEntry} has no \`default\` export. Sandboxed plugins must \`export default { hooks, routes } satisfies SandboxedPlugin\` from "emdash/plugin". A named-only export (e.g. \`export const plugin = ...\`) produces an empty bundle.`, + ); + } + const definition = pluginModule.default as Record; if (typeof definition !== "object" || definition === null || Array.isArray(definition)) { throw new BuildPipelineError( "INVALID_PLUGIN_FORMAT", diff --git a/packages/plugin-cli/src/init/templates.ts b/packages/plugin-cli/src/init/templates.ts index a5ee74a02..83d1ace65 100644 --- a/packages/plugin-cli/src/init/templates.ts +++ b/packages/plugin-cli/src/init/templates.ts @@ -288,7 +288,12 @@ export function renderGitignore(): string { * marketing copy. */ export function renderReadme(input: ScaffoldInputs): string { + // The slug is the package title in headings + the import specifier, + // but it can contain hyphens (e.g. `my-plugin`) which aren't legal + // JS identifiers. Derive a camelCase binding name for the import + + // integration call. const title = input.slug; + const importBinding = toCamelCase(input.slug); return `# ${title} A sandboxed plugin for [EmDash CMS](https://emdashcms.com). @@ -303,8 +308,8 @@ pnpm test To test against a running EmDash site, run \`pnpm dev\` in this directory (rebuilds on save) and \`pnpm add file:../path/to/this\` -in the site. Then \`import ${title} from "${input.slug}"\` and pass -it into \`emdash({ sandboxed: [${title}] })\`. +in the site. Then \`import ${importBinding} from "${input.slug}"\` and pass +it into \`emdash({ sandboxed: [${importBinding}] })\`. ## Publish @@ -377,3 +382,15 @@ function makeTestContext() { function jsonString(value: string): string { return JSON.stringify(value); } + +const SLUG_SEPARATOR_RE = /[-_]([a-z0-9])/g; + +/** + * Convert a plugin slug (`my-plugin`, `my_plugin`) into a JS identifier + * for use as an import binding. Slugs are validated to start with a + * letter (see `PLUGIN_SLUG_RE`), so the result is always a legal + * identifier. + */ +function toCamelCase(slug: string): string { + return slug.replace(SLUG_SEPARATOR_RE, (_, ch: string) => ch.toUpperCase()); +} diff --git a/packages/plugin-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts index 37a1c9207..f47a72221 100644 --- a/packages/plugin-cli/tests/init-templates.test.ts +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -267,6 +267,15 @@ describe("renderReadme", () => { const source = renderReadme(FULL_INPUTS); expect(source.split("\n")[0]).toBe("# gallery"); }); + + it("camel-cases the import binding so hyphenated slugs produce valid JS", () => { + const source = renderReadme({ ...FULL_INPUTS, slug: "my-plugin" }); + // The import specifier is the slug as-is; the binding must be a + // legal JS identifier (`myPlugin`, not `my-plugin`). + expect(source).toContain('import myPlugin from "my-plugin"'); + expect(source).toContain("sandboxed: [myPlugin]"); + expect(source).not.toContain('import my-plugin'); + }); }); // ────────────────────────────────────────────────────────────────────────── diff --git a/packages/plugins/audit-log/src/plugin.ts b/packages/plugins/audit-log/src/plugin.ts index cf4b0d5e7..9d2e7de3f 100644 --- a/packages/plugins/audit-log/src/plugin.ts +++ b/packages/plugins/audit-log/src/plugin.ts @@ -32,6 +32,18 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Coerce a content `id` to a string for use as a cache key or storage + * lookup. Accepts strings and numbers (the canonical ID types); + * everything else (objects, nulls, undefineds) becomes an empty string + * so the caller's existence check (`if (contentId)`) skips the entry. + */ +function stringifyId(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return ""; +} + function isAuditEntry(value: unknown): value is AuditEntry { return ( isRecord(value) && @@ -68,9 +80,8 @@ export default { "content:beforeSave": { handler: async (event, ctx) => { - if (!event.isNew && event.content.id) { - const contentId = - typeof event.content.id === "string" ? event.content.id : String(event.content.id); + const contentId = stringifyId(event.content.id); + if (!event.isNew && contentId) { try { if (ctx.content) { const existing = await ctx.content.get(event.collection, contentId); @@ -88,8 +99,7 @@ export default { "content:afterSave": { handler: async (event, ctx) => { - const contentId = - typeof event.content.id === "string" ? event.content.id : String(event.content.id ?? ""); + const contentId = stringifyId(event.content.id); const cacheKey = `${event.collection}:${contentId}`; const before = beforeSaveCache.get(cacheKey); beforeSaveCache.delete(cacheKey); From 87334edad0a074794a7aa4b4af60b2fe9be418ad Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sat, 16 May 2026 08:03:43 +0000 Subject: [PATCH 14/16] style: format --- packages/plugin-cli/tests/init-templates.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts index f47a72221..ce2c0d2cc 100644 --- a/packages/plugin-cli/tests/init-templates.test.ts +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -274,7 +274,7 @@ describe("renderReadme", () => { // legal JS identifier (`myPlugin`, not `my-plugin`). expect(source).toContain('import myPlugin from "my-plugin"'); expect(source).toContain("sandboxed: [myPlugin]"); - expect(source).not.toContain('import my-plugin'); + expect(source).not.toContain("import my-plugin"); }); }); From 7aa1762ddf31395a06470796a83277e4688b3376 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 16 May 2026 09:33:52 +0100 Subject: [PATCH 15/16] fix(plugin-cli): bump test timeout to 30s for bundle tests on slow CI bundle.test.ts > 'produces a tarball + manifest for a minimal valid plugin' timed out at the 5s default on the GitHub-hosted runner. The test runs the full build pipeline (tsdown probe + transpile + tarball pack), which is fast locally (<2s) but cold-starts at 5-8s on CI. Bump to 30s globally for the plugin-cli vitest config. --- packages/plugin-cli/vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/plugin-cli/vitest.config.ts b/packages/plugin-cli/vitest.config.ts index 59f73906d..21f373d28 100644 --- a/packages/plugin-cli/vitest.config.ts +++ b/packages/plugin-cli/vitest.config.ts @@ -4,5 +4,8 @@ export default defineConfig({ test: { environment: "node", include: ["tests/**/*.test.ts"], + // Bundle / build tests run a full tsdown probe + transpile, + // which is fast locally but can take >5s on cold CI runners. + testTimeout: 30_000, }, }); From 7ffc93cf9bb436d67cd16c84f8b82ec391de35d4 Mon Sep 17 00:00:00 2001 From: "ask-bonk[bot]" Date: Mon, 18 May 2026 11:27:38 +0000 Subject: [PATCH 16/16] chore: update lockfile --- pnpm-lock.yaml | 275 ++++++++++++++++++++++++------------------------- 1 file changed, 132 insertions(+), 143 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbddb70c6..4aed6da41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,15 +391,15 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/plugin-cli': - specifier: workspace:* - version: link:../../packages/plugin-cli '@tanstack/react-query': specifier: 'catalog:' version: 5.90.21(react@19.2.4) @@ -480,12 +480,12 @@ importers: '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log - '@emdash-cms/plugin-embeds': - specifier: workspace:* - version: link:../../packages/plugins/embeds '@emdash-cms/plugin-cli': specifier: workspace:* version: link:../../packages/plugin-cli + '@emdash-cms/plugin-embeds': + specifier: workspace:* + version: link:../../packages/plugins/embeds '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier @@ -609,12 +609,12 @@ importers: '@emdash-cms/plugin-audit-log': specifier: workspace:* version: link:../../packages/plugins/audit-log - '@emdash-cms/plugin-color': - specifier: workspace:* - version: link:../../packages/plugins/color '@emdash-cms/plugin-cli': specifier: workspace:* version: link:../../packages/plugin-cli + '@emdash-cms/plugin-color': + specifier: workspace:* + version: link:../../packages/plugins/color astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -685,9 +685,6 @@ importers: '@emdash-cms/plugin-color': specifier: workspace:* version: link:../../packages/plugins/color - '@emdash-cms/plugin-cli': - specifier: workspace:* - version: link:../../packages/plugin-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -767,15 +764,15 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/plugin-cli': - specifier: workspace:* - version: link:../../packages/plugin-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -810,15 +807,15 @@ importers: '@emdash-cms/cloudflare': specifier: workspace:* version: link:../../packages/cloudflare + '@emdash-cms/plugin-cli': + specifier: workspace:* + version: link:../../packages/plugin-cli '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/plugin-cli': - specifier: workspace:* - version: link:../../packages/plugin-cli astro: specifier: https://pkg.pr.new/astro@94d342d version: https://pkg.pr.new/astro@94d342d(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -1641,6 +1638,82 @@ importers: specifier: 'catalog:' version: 4.90.0(@cloudflare/workers-types@4.20260305.1) + packages/plugin-cli: + dependencies: + '@atcute/client': + specifier: 'catalog:' + version: 4.2.1 + '@atcute/identity-resolver': + specifier: 'catalog:' + version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@6.0.3))(@atcute/lexicons@1.3.0)(typescript@6.0.3) + '@atcute/lexicons': + specifier: 'catalog:' + version: 1.3.0 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 + '@atcute/oauth-node-client': + specifier: 'catalog:' + version: 1.1.0 + '@clack/prompts': + specifier: ^1.4.0 + version: 1.4.0 + '@emdash-cms/plugin-types': + specifier: workspace:* + version: link:../plugin-types + '@emdash-cms/registry-client': + specifier: workspace:* + version: link:../registry-client + '@emdash-cms/registry-lexicons': + specifier: workspace:* + version: link:../registry-lexicons + '@oslojs/crypto': + specifier: 'catalog:' + version: 1.0.1 + chokidar: + specifier: 'catalog:' + version: 5.0.0 + citty: + specifier: ^0.1.6 + version: 0.1.6 + consola: + specifier: ^3.4.2 + version: 3.4.2 + image-size: + specifier: ^2.0.2 + version: 2.0.2 + jsonc-parser: + specifier: 'catalog:' + version: 3.3.1 + modern-tar: + specifier: ^0.7.5 + version: 0.7.6 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) + zod: + specifier: 'catalog:' + version: 4.4.1 + devDependencies: + '@arethetypeswrong/cli': + specifier: 'catalog:' + version: 0.18.2 + '@types/node': + specifier: 'catalog:' + version: 24.10.13 + publint: + specifier: 'catalog:' + version: 0.3.17 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) + packages/plugin-types: devDependencies: '@arethetypeswrong/cli': @@ -1710,7 +1783,7 @@ importers: version: 3.3.1 tsdown: specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.0-beta) + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) typescript: specifier: 'catalog:' version: 6.0.3 @@ -1872,79 +1945,6 @@ importers: specifier: 'catalog:' version: 6.0.3 - packages/plugin-cli: - dependencies: - '@atcute/client': - specifier: 'catalog:' - version: 4.2.1 - '@atcute/identity-resolver': - specifier: 'catalog:' - version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@1.3.0)(typescript@6.0.3))(@atcute/lexicons@1.3.0)(typescript@6.0.3) - '@atcute/lexicons': - specifier: 'catalog:' - version: 1.3.0 - '@atcute/multibase': - specifier: 'catalog:' - version: 1.2.0 - '@atcute/oauth-node-client': - specifier: 'catalog:' - version: 1.1.0 - '@emdash-cms/plugin-types': - specifier: workspace:* - version: link:../plugin-types - '@emdash-cms/registry-client': - specifier: workspace:* - version: link:../registry-client - '@emdash-cms/registry-lexicons': - specifier: workspace:* - version: link:../registry-lexicons - '@oslojs/crypto': - specifier: 'catalog:' - version: 1.0.1 - chokidar: - specifier: 'catalog:' - version: 5.0.0 - citty: - specifier: ^0.1.6 - version: 0.1.6 - consola: - specifier: ^3.4.2 - version: 3.4.2 - image-size: - specifier: ^2.0.2 - version: 2.0.2 - jsonc-parser: - specifier: 'catalog:' - version: 3.3.1 - modern-tar: - specifier: ^0.7.5 - version: 0.7.6 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - tsdown: - specifier: 'catalog:' - version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) - zod: - specifier: 'catalog:' - version: 4.4.1 - devDependencies: - '@arethetypeswrong/cli': - specifier: 'catalog:' - version: 0.18.2 - '@types/node': - specifier: 'catalog:' - version: 24.10.13 - publint: - specifier: 'catalog:' - version: 0.3.17 - typescript: - specifier: 'catalog:' - version: 6.0.3 - vitest: - specifier: 'catalog:' - version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) - packages/registry-client: dependencies: '@atcute/atproto': @@ -2115,9 +2115,6 @@ importers: '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier - '@emdash-cms/plugin-cli': - specifier: workspace:* - version: link:../../packages/plugin-cli astro: specifier: 'catalog:' version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.55.2)(typescript@6.0.0-beta)(yaml@2.8.2) @@ -2995,12 +2992,20 @@ packages: '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} + '@clack/core@1.3.1': + resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + engines: {node: '>= 20.12.0'} + '@clack/prompts@0.10.1': resolution: {integrity: sha512-Q0T02vx8ZM9XSv9/Yde0jTmmBQufZhPJfYAg2XrrrxWWaZgq1rr8nU8Hv710BQ1dhoP8rtY7YUdpGej2Qza/cw==} '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} + '@clack/prompts@1.4.0': + resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + engines: {node: '>= 20.12.0'} + '@cloudflare/kumo@1.16.0': resolution: {integrity: sha512-uCrj7jGPvdXj8lrdQBfMGKzV3JTDi7hUBsLf4jpirD7QHvZMsGe6XuU+KKvQFqDTmj5ELXQVES4YVoducxZ7Tg==} hasBin: true @@ -7307,9 +7312,18 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -11815,6 +11829,11 @@ snapshots: dependencies: sisteransi: 1.0.5 + '@clack/core@1.3.1': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@clack/prompts@0.10.1': dependencies: '@clack/core': 0.4.2 @@ -11826,6 +11845,13 @@ snapshots: '@clack/core': 1.1.0 sisteransi: 1.0.5 + '@clack/prompts@1.4.0': + dependencies: + '@clack/core': 1.3.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + '@cloudflare/kumo@1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.1)': dependencies: '@base-ui/react': 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -15165,7 +15191,7 @@ snapshots: '@astrojs/markdown-remark': 7.0.0-beta.11 '@astrojs/telemetry': 3.3.0 '@capsizecss/unpack': 4.0.0 - '@clack/prompts': 1.1.0 + '@clack/prompts': 1.4.0 '@oslojs/encoding': 1.1.0 '@rollup/pluginutils': 5.3.0(rollup@4.55.2) aria-query: 5.3.2 @@ -16531,8 +16557,18 @@ snapshots: fast-redact@3.5.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -18980,24 +19016,6 @@ snapshots: reusify@1.0.4: {} - rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@6.0.0-beta): - dependencies: - '@babel/generator': 8.0.0-rc.1 - '@babel/helper-validator-identifier': 8.0.0-rc.1 - '@babel/parser': 8.0.0-rc.1 - '@babel/types': 8.0.0-rc.1 - ast-kit: 3.0.0-beta.1 - birpc: 4.0.0 - dts-resolver: 2.1.3(oxc-resolver@11.16.4) - get-tsconfig: 4.13.6 - obug: 2.1.1 - rolldown: 1.0.0-rc.3 - optionalDependencies: - '@typescript/native-preview': 7.0.0-dev.20260421.2 - typescript: 6.0.0-beta - transitivePeerDependencies: - - oxc-resolver - rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.1 @@ -19586,35 +19604,6 @@ snapshots: optionalDependencies: typescript: 6.0.3 - tsdown@0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.0-beta): - dependencies: - ansis: 4.2.0 - cac: 6.7.14 - defu: 6.1.4 - empathic: 2.0.0 - hookable: 6.0.1 - import-without-cache: 0.2.5 - obug: 2.1.1 - picomatch: 4.0.4 - rolldown: 1.0.0-rc.3 - rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@6.0.0-beta) - semver: 7.7.4 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - unconfig-core: 7.4.2 - unrun: 0.2.28 - optionalDependencies: - '@arethetypeswrong/core': 0.18.2 - publint: 0.3.17 - typescript: 6.0.0-beta - transitivePeerDependencies: - - '@ts-macro/tsc' - - '@typescript/native-preview' - - oxc-resolver - - synckit - - vue-tsc - tsdown@0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3): dependencies: ansis: 4.2.0