Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/busy-rivers-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@emdash-cms/registry-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.

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"`.
3 changes: 2 additions & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"**/*.mdx",
"**/package.json",
"**/emdash-env.d.ts",
"packages/registry-lexicons/src/generated/**"
"packages/registry-lexicons/src/generated/**",
"packages/registry-cli/schemas/**"
]
}
52 changes: 51 additions & 1 deletion packages/registry-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ emdash-registry search <query> Free-text search
emdash-registry info <handle-or-did> <slug> Show package details
emdash-registry bundle Bundle a plugin source dir into a tarball
emdash-registry publish --url <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 <url>` (or `EMDASH_REGISTRY_URL`).
Expand All @@ -42,7 +43,56 @@ emdash-registry bundle
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.
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",
}
```

Comment thread
ascorbic marked this conversation as resolved.
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 <did>`
- 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

Expand Down
12 changes: 8 additions & 4 deletions packages/registry-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
"emdash-registry": "./dist/index.mjs"
},
"files": [
"dist"
"dist",
"schemas"
],
"scripts": {
"build": "tsdown",
"build": "node --run gen-schema && tsdown",
"dev": "tsdown --watch",
"gen-schema": "node --no-warnings --experimental-strip-types scripts/gen-schema.ts",
"prepublishOnly": "node --run build",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
Expand All @@ -30,16 +32,18 @@
"@atcute/lexicons": "catalog:",
"@atcute/multibase": "catalog:",
"@atcute/oauth-node-client": "catalog:",
"@oslojs/crypto": "catalog:",
"@emdash-cms/plugin-types": "workspace:*",
"@emdash-cms/registry-client": "workspace:*",
"@emdash-cms/registry-lexicons": "workspace:*",
"@oslojs/crypto": "catalog:",
"citty": "^0.1.6",
"consola": "^3.4.2",
"image-size": "^2.0.2",
"jsonc-parser": "catalog:",
"modern-tar": "^0.7.5",
"picocolors": "^1.1.1",
"tsdown": "catalog:"
"tsdown": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
Expand Down
204 changes: 204 additions & 0 deletions packages/registry-cli/schemas/emdash-plugin.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://emdashcms.com/schemas/emdash-plugin.schema.json",
"title": "EmDash plugin manifest",
"description": "Hand-authored manifest for publishing a plugin to the EmDash plugin registry. Lives next to the plugin's `package.json` as `emdash-plugin.jsonc`.",
"type": "object",
"properties": {
"$schema": {
"$ref": "#/$defs/__schema0"
},
"license": {
"$ref": "#/$defs/__schema1"
},
"publisher": {
"$ref": "#/$defs/__schema2"
},
"author": {
"$ref": "#/$defs/__schema3"
},
"authors": {
"$ref": "#/$defs/__schema8"
},
"security": {
"$ref": "#/$defs/__schema9"
},
"securityContacts": {
"$ref": "#/$defs/__schema13"
},
"name": {
"$ref": "#/$defs/__schema14"
},
"description": {
"$ref": "#/$defs/__schema15"
},
"keywords": {
"$ref": "#/$defs/__schema16"
},
"repo": {
"$ref": "#/$defs/__schema18"
}
},
"required": [
"license"
],
"additionalProperties": false,
"$defs": {
"__schema0": {
"type": "string",
"description": "Path or URL to the JSON Schema describing this file. Editors use this for completion and validation."
},
"__schema1": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"title": "License",
"description": "SPDX license expression (e.g. \"MIT\", \"Apache-2.0\", \"MIT OR Apache-2.0\"). Required on first publish; ignored on subsequent publishes (the existing profile wins).",
"examples": [
"MIT",
"Apache-2.0",
"MIT OR Apache-2.0"
]
},
"__schema2": {
"type": "string",
"title": "Publisher",
"description": "Atproto DID or handle of the publishing identity. Pinned on first publish to prevent accidental publishes from a different account. DIDs are recommended (durable); handles work but are mutable.",
"examples": [
"did:plc:abc123def456",
"example.com"
]
},
"__schema3": {
"$ref": "#/$defs/__schema4"
},
"__schema4": {
"type": "object",
"properties": {
"name": {
"$ref": "#/$defs/__schema5"
},
"url": {
"$ref": "#/$defs/__schema6"
},
"email": {
"$ref": "#/$defs/__schema7"
}
},
"required": [
"name"
],
"additionalProperties": false,
"title": "Author",
"description": "A single author entry. Mirrors the lexicon's author shape."
},
"__schema5": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Display name."
},
"__schema6": {
"type": "string",
"maxLength": 1024,
"format": "uri",
"description": "Author's homepage or profile URL. Either this or `email` is recommended."
},
"__schema7": {
"type": "string",
"maxLength": 256,
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
"description": "Author's contact email. Either this or `url` is recommended."
},
"__schema8": {
"minItems": 1,
"maxItems": 32,
"type": "array",
"items": {
"$ref": "#/$defs/__schema4"
},
"title": "Authors (multiple)",
"description": "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one."
},
"__schema9": {
"$ref": "#/$defs/__schema10"
},
"__schema10": {
"type": "object",
"properties": {
"url": {
"$ref": "#/$defs/__schema11"
},
"email": {
"$ref": "#/$defs/__schema12"
}
},
"additionalProperties": false,
"title": "Security contact",
"description": "A single security contact. At least one of `url` or `email` must be present."
},
"__schema11": {
"type": "string",
"maxLength": 1024,
"format": "uri",
"description": "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required."
},
"__schema12": {
"type": "string",
"maxLength": 256,
"format": "email",
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
"description": "Security contact email. Either this or `url` is required."
},
"__schema13": {
"minItems": 1,
"maxItems": 8,
"type": "array",
"items": {
"$ref": "#/$defs/__schema10"
},
"title": "Security contacts (multiple)",
"description": "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one."
},
"__schema14": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"title": "Display name",
"description": "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted."
},
"__schema15": {
"type": "string",
"minLength": 1,
"maxLength": 1024,
"title": "Description",
"description": "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists."
},
"__schema16": {
"maxItems": 5,
"type": "array",
"items": {
"$ref": "#/$defs/__schema17"
},
"title": "Keywords",
"description": "Search keywords (<= 5 entries, FAIR convention)."
},
"__schema17": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"__schema18": {
"type": "string",
"maxLength": 1024,
"format": "uri",
"pattern": "^https:\\/\\/",
"title": "Source repository",
"description": "HTTPS URL of the plugin's source repository. Surfaced in registry listings.",
"examples": [
"https://github.com/emdash-cms/plugin-gallery"
]
}
}
}
60 changes: 60 additions & 0 deletions packages/registry-cli/scripts/gen-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Generate the JSON Schema for `emdash-plugin.jsonc` from the Zod source
* of truth in `src/manifest/schema.ts`.
*
* Run via `pnpm gen-schema` (wired into `build`). The output is committed
* 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"
*
* Drift between the Zod schema and the committed JSON Schema is caught
* by the snapshot test in `tests/schema.test.ts`.
*
* Why a separate script rather than emitting on build:
*
* - The schema is part of the package's user-facing surface; checking
* it into git makes diffs visible in PR review (a field rename in
* Zod produces a tracked diff in the JSON Schema too).
* - Tests can run without first building. The schema file exists
* at-rest; the test compares Zod's current output to it.
*
* Runs under Node's native TypeScript stripping (Node 22+). No `tsx` or
* `ts-node` dependency.
*/

import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { z } from "zod";

import { ManifestSchema } from "../src/manifest/schema.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const OUT_PATH = resolve(HERE, "..", "schemas", "emdash-plugin.schema.json");

// zod 4's native JSON Schema emitter. `target: "draft-2020-12"` is what
// every modern JSON Schema editor (VS Code's built-in schema store,
// IntelliJ's JSON LSP) supports out of the box.
const jsonSchema = z.toJSONSchema(ManifestSchema, {
target: "draft-2020-12",
// Use full reuse rather than inline-everything: smaller file, easier
// diffs when a single subschema changes.
reused: "ref",
});

const document = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: "https://emdashcms.com/schemas/emdash-plugin.schema.json",
title: "EmDash plugin manifest (emdash-plugin.jsonc)",
description:
"Authoring format for publishing plugins to the EmDash plugin registry. Translated to the on-wire atproto record format at publish time. See https://github.com/emdash-cms/emdash/issues/1028.",
...jsonSchema,
};

const serialised = `${JSON.stringify(document, null, "\t")}\n`;

await mkdir(dirname(OUT_PATH), { recursive: true });
await writeFile(OUT_PATH, serialised, "utf8");
process.stdout.write(`Wrote ${OUT_PATH}\n`);
Loading
Loading