feat(registry-cli): emdash-plugin.jsonc manifest, validate command, publisher pinning#1040
Conversation
…ublisher pinning Foundation for the registry roadmap. Adds the file format, loader, and schema; no change to discovery or publish behaviour beyond now reading the manifest by default. - Zod schema for `emdash-plugin.jsonc` with v1 profile fields (license, author/authors, security/securityContacts, name, description, keywords, repo). Strict mode rejects unknown keys to catch typos. - JSONC loader with `tsc`-style file:line:column diagnostics. - `emdash-registry validate` for offline schema checking, suitable for pre-commit and CI. - `publish` reads the manifest from `./emdash-plugin.jsonc` by default; explicit flags still win for CI overrides. `--no-manifest` opts out. - New `publisher` field pins the publishing identity. On first publish, the CLI writes the active session's DID back as `publisher`, with a `// <handle>` line comment for readability. Subsequent publishes verify the active session matches the pinned publisher. - JSON Schema generated from the Zod source via z.toJSONSchema; ships in the package at `schemas/emdash-plugin.schema.json` for IDE completion. Drift caught by a snapshot test. Part of #1028 (the init command lands separately in a follow-up).
…rnings Adversarial review findings: - Annotation in writePublisherBack now anchors to the publisher property via the parse tree, not a raw DID string search. Previously, a description (or any other field) that contained the DID would have taken the // <handle> comment. - Loader rejects manifests over 1 MiB (MANIFEST_TOO_LARGE) so a mistyped --manifest at a huge file fails fast instead of OOMing. - Loader rejects duplicate JSONC keys with a source location, so a reviewer can't be fooled by an honest "publisher": "did:plc:X" at the top of the file that's silently overridden by a hostile value below. - writePublisherBack hashes the original source, re-reads the file immediately before rename, and aborts if the bytes changed. Narrows the TOCTOU window between concurrent publishes / editor saves. - publish accepts --manifest=false and --manifest= as opt-out shorthands, matching the help text. - publish warns when --no-manifest is set and a manifest exists at the default path, so the publisher pin's safety story stays visible. Cleanup: - Removed the findUnwiredManifestFields helper and the in-product warnings about fields the schema accepts but publish doesn't yet consume. The release ships when the work is done; leaking intermediate state into the CLI is noise. Tests: 170 passing.
…failure
Adversarial review round 3 findings:
- Replace stat+readFile with a bounded read primitive in both the
loader and writePublisherBack. open() + a pre-allocated
MANIFEST_MAX_BYTES+1 buffer means the file can't grow between
syscalls to OOM the CLI. Returns a discriminated union so the type
system enforces handling oversize/missing/ok cases.
- writePublisherBack's drift-check read now routes unexpected fs
errors (EISDIR after file replacement, EACCES, etc.) through the
same tmpfile-cleanup path as legitimate drift. Previously an
unexpected throw would leak the tmpfile next to the manifest.
- Fix stale doc comments in load.ts (failure-modes list missing
MANIFEST_TOO_LARGE and duplicate-key) and publish.ts (no longer
warns about unwired fields).
- Replace lazy `await import("node:fs/promises")` with a top-of-file
import; the dynamic form was carry-over from an earlier iteration.
- New test asserts MANIFEST_TOO_LARGE fires on an oversized file.
🦋 Changeset detectedLatest commit: a763150 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-perf-coordinator | a763150 | May 14 2026, 01:25 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-i18n | a763150 | May 14 2026, 01:25 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | a763150 | May 14 2026, 01:26 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | a763150 | May 14 2026, 01:27 PM |
Scope checkThis PR changes 2,806 lines across 19 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | a763150 | May 14 2026, 01:27 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Adds an emdash-plugin.jsonc manifest format to @emdash-cms/registry-cli so plugin authors can declare profile fields once instead of repeating them as flags on every publish. Introduces a Zod-driven schema, JSONC loader with source-mapped diagnostics, an offline validate command, publisher-DID pinning with safe write-back, and a generated JSON Schema for IDE completion. This is the foundation PR (#1028) for the broader registry roadmap (#1026); subsequent PRs will wire the richer manifest fields through the publish payload (#1029) and beyond.
Changes:
- New
manifest/{schema,load,translate,publisher}.tsmodules: strict-mode Zod schema, JSONC loader withtsc-style file:line:column diagnostics, bounded 1 MiB read, duplicate-key rejection, and atomic write-back of the session DID with TOCTOU narrowing. - New
emdash-registry validatecommand and updatedpublishcommand that auto-loads./emdash-plugin.jsonc, layers it under explicit flags, verifies pinned publisher matches active session, and warns when--no-manifestopts out while a manifest exists. - JSON Schema generation script (
scripts/gen-schema.ts) emitsschemas/emdash-plugin.schema.json, guarded by a snapshot drift test;jsonc-parserandzodadded as runtime deps.
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-workspace.yaml | Add infra/plugins-site workspace + jsonc-parser catalog entry; bump zod to ^4.4.1 (and remove its rationale comment). |
| pnpm-lock.yaml | Lockfile updates for new catalog/workspace entries. |
| packages/registry-cli/src/manifest/schema.ts | Zod schema + field-level exports for the manifest, with strict-mode and refine-based mutual-exclusivity rules. |
| packages/registry-cli/src/manifest/load.ts | Bounded JSONC loader with parse-error and Zod-issue source mapping; duplicate-key detection. |
| packages/registry-cli/src/manifest/translate.ts | Normalises single/multi author+contact forms and bridges to ProfileBootstrap. |
| packages/registry-cli/src/manifest/publisher.ts | checkPublisher (DID/handle compare) + writePublisherBack with atomic rename, hash-based TOCTOU narrowing, and JSONC-aware annotation. |
| packages/registry-cli/src/commands/publish.ts | Loads manifest, runs publisher check, layers manifest under flags, and triggers post-publish write-back. |
| packages/registry-cli/src/commands/validate.ts | New offline validate command with human + --json output paths. |
| packages/registry-cli/src/index.ts | Registers the validate subcommand. |
| packages/registry-cli/scripts/gen-schema.ts | Generates the committed JSON Schema from the Zod source of truth. |
| packages/registry-cli/schemas/emdash-plugin.schema.json | Committed JSON Schema, shipped for $schema-driven IDE completion. |
| packages/registry-cli/package.json | Adds runtime deps (jsonc-parser, zod), gen-schema script, schemas/templates to files. |
| packages/registry-cli/README.md | Documents the manifest format, publisher pinning behavior, and validate command. |
| packages/registry-cli/tests/* | Coverage for schema, loader, translate, publisher check/write-back, and JSON Schema drift. |
| .changeset/busy-rivers-drive.md | Minor changeset for @emdash-cms/registry-cli. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| typescript: ^5.9.3 | ||
| vite: ^8.0.11 | ||
| vitest: ^4.1.5 | ||
| wrangler: ^4.83.0 |
| export function manifestToProfileBootstrap(manifest: NormalisedManifest): ProfileBootstrap { | ||
| const author = manifest.authors[0]; | ||
| const security = manifest.securityContacts[0]; | ||
|
|
||
| const profile: ProfileBootstrap = { license: manifest.license }; | ||
| if (author?.name !== undefined) profile.authorName = author.name; | ||
| if (author?.url !== undefined) profile.authorUrl = author.url; | ||
| if (author?.email !== undefined) profile.authorEmail = author.email; | ||
| if (security?.email !== undefined) profile.securityEmail = security.email; | ||
| if (security?.url !== undefined) profile.securityUrl = security.url; | ||
| return profile; | ||
| } |
- Restore the zod catalog-pin rationale comment that got dropped when pnpm add rewrote pnpm-workspace.yaml. The comment is still relevant after the 4.4.1 bump. - Drop "templates" from the package files array. The directory lands with the init command in a follow-up PR; including a non-existent path causes pnpm pack warnings. - Document in translate.ts that name/description/keywords/repo are intentionally deferred to a follow-up issue, not silently lost.
|
/review |
There was a problem hiding this comment.
Adversarial review. One CI-blocking bug, plus a few smaller items.
The CRITICAL one: the schema-drift test is failing on this PR right now. The style: format commit (f565fa3) collapsed short JSON arrays in schemas/emdash-plugin.schema.json from multi-line to single-line, but the drift test compares byte-for-byte against JSON.stringify(..., "\t") which always emits multi-line arrays. pnpm test reports 1 failed | 82 passed in this package. The PR description's "171 passed" line is stale.
Reproduced locally: cd packages/registry-cli && pnpm test tests/schema-drift.test.ts fails with the exact diff shown in the comment on the schema file. This is a recurring flap: pnpm build regenerates the schema, then pnpm format collapses it again, then the drift test fails. Either the generator needs to emit oxfmt-canonical JSON, or the schema needs to live in .prettierignore/oxfmt's ignore list, or the drift test needs to compare structurally rather than byte-for-byte.
| "$ref": "#/$defs/__schema18" | ||
| } | ||
| }, | ||
| "required": ["license"], |
There was a problem hiding this comment.
CRITICAL — CI-blocking, the PR's own drift test fails on this file.
This file was reformatted by pnpm format (commit f565fa3 style: format from emdashbot) after gen-schema produced it. Oxfmt collapsed every short array to a single line:
"required": ["license"](here)"examples": ["MIT", "Apache-2.0", "MIT OR Apache-2.0"]"examples": ["did:plc:abc123def456", "example.com"]"required": ["name"]"examples": ["https://github.com/emdash-cms/plugin-gallery"]
But scripts/gen-schema.ts writes the file with JSON.stringify(document, null, "\t"), which always emits multi-line arrays. The drift test in tests/schema-drift.test.ts:51 compares byte-for-byte, so it now fails:
FAIL tests/schema-drift.test.ts > JSON Schema drift > matches the output of z.toJSONSchema(ManifestSchema)
Error: schemas/emdash-plugin.schema.json is out of date with the Zod schema.
Run: pnpm --filter @emdash-cms/registry-cli gen-schema
This is a flap loop: pnpm build regenerates → pnpm format collapses → drift test fails → run gen-schema → format → repeat. The PR description claims 171 passed (171) but the package currently reports 1 failed | 82 passed.
Three options to fix:
- Add this path to oxfmt's ignore list (and document why).
- Make
gen-schema.tspost-process the output to match oxfmt's array-collapsing rule. - Change the drift test to parse + structurally compare instead of byte-for-byte.
Option 1 is the cleanest — the file is a generated artifact, formatting it serves no review purpose.
| // of the object if `license` isn't present, which shouldn't | ||
| // happen for a schema-valid manifest but is handled defensively). | ||
| const edits = modify(source, ["publisher"], sessionDid, { | ||
| formattingOptions: { insertSpaces: false, tabSize: 1 }, |
There was a problem hiding this comment.
MEDIUM — modify will reformat the entire file when the user's source isn't tab-indented.
With formattingOptions: { insertSpaces: false, tabSize: 1 }, jsonc-parser.modify doesn't just insert the new property — when the surrounding source disagrees with the formatting options, it rewrites the affected region using the configured options. For a single-line compact manifest:
{ "license": "MIT", "author": { "name": "J" }, "security": { "email": "a@b.com" } }
the edit replaces bytes 0..83 with the entire tab-indented multi-line form. A user who deliberately authored a 2-space-indented or compact manifest will see their whole file silently restructured after first publish. The TOCTOU hash check still works, but the user-visible result is unexpected.
Not critical for v1, but worth either: documenting in the README that write-back forces tab indentation, OR detecting the existing indentation from the source (jsonc-parser doesn't expose this directly; you'd sniff the first indented line) and matching it.
| // before we open any network connections to the PDS. The check runs | ||
| // here (rather than at manifest-load time) because it depends on | ||
| // session.did, which we only know now. | ||
| if (manifestLoad?.manifest.publisher !== undefined) { |
There was a problem hiding this comment.
LOW (UX) — publisher mismatch check runs after a (potentially slow) tarball download.
The publisher check at lines 224–244 only needs manifestLoad.manifest.publisher and session.did, both available at line 218. But the tarball fetch at line 185 has already completed, plus tar decompression and manifest extraction.
For a publisher who's accidentally got the wrong session active, this means waiting for an entire MAX_TARBALL_BYTES download (up to 384 KB over slow links) before getting the immediate MANIFEST_PUBLISHER_MISMATCH they could have gotten in ~10ms. The check is offline (DID compare is verbatim; handle resolution only runs if the manifest pins a handle, which the README discourages).
Move the check to right after credentials.current() returns a session and before the fetchTarball call. Same logic, no extra round-trips, just runs earlier.
| * are not silently lost — the manifest is the source of truth and we'll | ||
| * read them again when the publish API accepts them. | ||
| */ | ||
| export function manifestToProfileBootstrap(manifest: NormalisedManifest): ProfileBootstrap { |
There was a problem hiding this comment.
LOW — schema accepts fields that this function silently discards.
manifestToProfileBootstrap only consumes license, the first author, and the first security contact. The manifest schema validates name, description, keywords, repo, and authors[2..N]/securityContacts[2..N], but they're not surfaced to publishRelease. The README example at packages/registry-cli/README.md:60-65 advertises name/description/keywords/repo as functional optional fields.
The JSDoc on line 66-70 calls this out ("land in publish in a follow-up issue"), but a user reading the README + writing a populated manifest gets no feedback that their keywords array was parsed-and-thrown-away. At minimum, emit a one-time info log from runPublish when the manifest carries fields v1 doesn't wire through, so a publisher who fills out the full example doesn't silently lose data on every publish until #1029 lands.
Same for multi-author manifests: authors[1..N] are silently dropped. The schema accepts up to 32; the bootstrap takes the first. Surface a warning, or limit the schema to length 1 until the bootstrap can carry the array.
| * common for "required" issues: the field doesn't exist in the source, | ||
| * so we point at the parent object instead). | ||
| */ | ||
| function findNodeAtPath(root: Node, path: ReadonlyArray<string | number>): Node | undefined { |
There was a problem hiding this comment.
LOW — findNodeAtPath returns the parent node when traversing into a non-object/non-array, but returns undefined when the child key doesn't exist.
For an unrecognized-key validation issue (e.g., user types "licens": "MIT"), Zod's path is ["licens"]. This function walks the root object, looks for property licens, doesn't find it, and current becomes undefined. On the next loop iteration if (!current) return undefined. So the issue gets no source location, and the user sees:
/path/manifest.jsonc: licens: unrecognized key
instead of /path/manifest.jsonc:3:2: licens: unrecognized key. Typos in field names are exactly the case the :line:column reporting was designed for, and they're the ones that lose the location.
Fix: when the final segment resolves to nothing, fall back to the parent object's offset. Or for unrecognized-keys specifically, the issue's keys array contains the offending key names — find them in the parent object's children directly.
|
Review posted successfully (ID 4289727949). Summary of what I found: CRITICAL: The schema-drift test is failing on the current PR HEAD. The MEDIUM: LOW: Publisher mismatch check runs after the tarball download instead of immediately after session resume — wastes bandwidth on the unhappy path. LOW: LOW: |
Five findings from the second-opinion review on PR #1040: CRITICAL: schema-drift CI flap - pnpm format collapsed short arrays in the generated JSON Schema (`required: ["license"]`, `examples: [...]`, etc.) into single-line form, but `JSON.stringify(_, null, "\t")` from gen-schema.ts always emits multi-line. The drift test failed on every PR after format ran, driving a regen-format-fail loop. Add the schemas dir to oxfmt's ignorePatterns — generated artifacts shouldn't be reformatted. LOW: typo'd field names lose source location - `findNodeAtPath` returned undefined when a path traversed into a missing key, so an unrecognized-key issue (the most common error) reported with no line:col. Now falls back to the parent node, and for `unrecognized_keys` issues specifically resolves the first offending key inside the parent object so the pointer lands on the actual typo's line+column instead of the parent's opening brace. LOW: publisher mismatch only fires after tarball fetch - Move the publisher-mismatch check ahead of fetchTarball so a wrong-account publish fails in ms rather than after a 384 KB download + decompress + manifest extract. The check is offline; it only needs the session DID and the manifest's publisher value, both available before any network access. MEDIUM: writePublisherBack reformats non-tab manifests - Hard-coding `insertSpaces: false, tabSize: 1` made jsonc-parser's modify() rewrite a 2-space-indented manifest with tabs on first publish. Sniff the source's indentation and match it. LOW: README advertises fields publish silently discards - Per maintainer direction (drop in-development warnings), this stays as a JSDoc note in translate.ts. README documents the target shape, not the current implementation. Already addressed in the previous commit.
What does this PR do?
Adds the
emdash-plugin.jsoncmanifest format and wires it into the existingemdash-registryCLI. 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. Addsemdash-registry validatefor offline schema checking. Pins the publisher identity on first publish via a newpublisherfield, written back by the CLI as a DID with a// <handle>line comment for readability.Foundation for the rest of the registry roadmap (issues #1029–#1036). The
initcommand from #1028 will land in a follow-up PR to keep this one reviewable.This PR went through three rounds of adversarial review by a different model family (GPT-5.5). Each round surfaced real bugs (notably: write-back TOCTOU, duplicate-key shadowing, OOM via stat/readFile race, annotation targeting wrong line). All findings are addressed; see commits 2 and 3 for the fixes.
Closes part of #1028. Tracking Discussion: #296
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (171 tests in@emdash-cms/registry-cli)pnpm formathas been runAI-generated code disclosure
Highlights
emdash-plugin.jsoncwith v1 profile fields. Strict mode rejects unknown keys to catch typos.tsc-style file:line:column diagnostics. Bounded 1 MiB read (MANIFEST_TOO_LARGE) so a mistyped path can't OOM the CLI. Duplicate keys rejected with a source location.emdash-registry validatefor offline schema checking, suitable for pre-commit / CI.publishreads the manifest from./emdash-plugin.jsoncby default; explicit flags still win for CI override.--no-manifestopts out and warns if a manifest exists at the default path so the publisher-pin safety story stays visible.publisherfield pins the publishing identity. On first publish, the CLI writes the active session's DID back aspublisher, with a// <handle>line comment. Subsequent publishes verify the active session matches the pinned publisher; mismatch is an immediate error (no override flag — the right fix is to switch sessions or update the manifest).z.toJSONSchema; ships in the package atschemas/emdash-plugin.schema.jsonfor IDE completion. Drift caught by a snapshot test. (Known limitation: Zod's.refine()cross-field rules don't translate to JSON Schema, so editor validation is looser than the CLI's. To be tightened in a follow-up.)Test output