Skip to content

feat(plugin-cli): add update-package command#1126

Open
ascorbic wants to merge 3 commits into
mainfrom
feat/registry-update-profile
Open

feat(plugin-cli): add update-package command#1126
ascorbic wants to merge 3 commits into
mainfrom
feat/registry-update-profile

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic commented May 20, 2026

What does this PR do?

Adds emdash-plugin update-package, a CLI command that lets publishers
edit an already-published package's metadata record (the registry's
com.emdashcms.experimental.package.profile record — distinct from the
publisher's atproto profile at app.bsky.actor.profile) without cutting
a new release. Closes the gap from the publish flow's "Profile updates
aren't supported yet" warning, which now points users at this command.

Behaviour:

  • Dry-run by default: reads the manifest, fetches the existing record
    from the publisher's PDS, diffs the lexicon-controlled fields, and
    prints what would change.
  • --yes writes via com.atproto.repo.putRecord and bumps
    lastUpdated. --json mirrors the publish command's machine-readable
    output shape.
  • Editable fields: license, authors, security, name,
    description, keywords. Identity ($type, id, slug, type)
    and unknown forward-compatible fields (e.g. sections from Registry: long-form sections (description, installation, changelog, faq, security) end-to-end #1030)
    pass through verbatim.
  • Refuses to change the slug. If the manifest's slug doesn't match any
    published record but a different-slug package exists for the
    publisher, surfaces a clear POSSIBLE_RENAME warning so a manifest
    rename doesn't quietly orphan releases under the old slug.

Closes #1032
Parent: #1026 (Discussion #296)

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. (N/A — CLI only, no admin UI changes)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Marketplace Discussion #296

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7

Screenshots / test output

```
Test Files 17 passed (17)
Tests 282 passed (282)
```

18 new test cases cover dry-run diff detection, apply-with-write
semantics, unknown-field preservation, `INVALID_INPUT` refusals for
empty arrays / contacts with no url-or-email, `POSSIBLE_RENAME`
detection, `SLUG_MISMATCH` for hand-edited records, name-only diffs,
and keywords array-order changes.

Lets publishers edit an already-published profile (license, authors,
security contacts, name/description/keywords) without cutting a new
release. Dry-run by default; `--yes` writes via putRecord and bumps
lastUpdated. Refuses to change the slug, and surfaces a clear rename
warning when the manifest slug has drifted from the published one.

Closes the gap from the publish flow's "Profile updates aren't supported
yet" warning, which now points users at this command.
Copilot AI review requested due to automatic review settings May 20, 2026 20:57
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

🦋 Changeset detected

Latest commit: fa90364

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@emdash-cms/plugin-cli Minor
@emdash-cms/registry-client Minor
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,299 lines across 6 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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 20, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-perf-coordinator fa90364 May 20 2026, 09:40 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 20, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-i18n fa90364 May 20 2026, 09:40 PM

@ascorbic
Copy link
Copy Markdown
Collaborator Author

/review

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 20, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs fa90364 May 20 2026, 09:41 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 20, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache fa90364 May 20 2026, 09:42 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 20, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground fa90364 May 20 2026, 09:42 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1126

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1126

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1126

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1126

emdash

npm i https://pkg.pr.new/emdash@1126

create-emdash

npm i https://pkg.pr.new/create-emdash@1126

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1126

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1126

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1126

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1126

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1126

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1126

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1126

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1126

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1126

commit: fa90364

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new emdash-plugin update-profile command (and programmatic API) to update an already-published com.emdashcms.experimental.package.profile record in-place (dry-run by default; --yes applies via com.atproto.repo.putRecord), and updates publish messaging to direct users to this command.

Changes:

  • Added updateProfile programmatic API (diff + optional write) and comprehensive unit tests using MockPds.
  • Added new update-profile CLI command with human and --json output modes, plus wiring into the CLI entrypoint.
  • Updated publish warning text to point users at emdash-plugin update-profile, and added a changeset for the new feature.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/plugin-cli/tests/update-profile.test.ts New test suite covering diffing, writes, refusal modes, and forward-compatible field preservation.
packages/plugin-cli/src/update-profile/api.ts New exported programmatic API for reading/diffing/updating profile records.
packages/plugin-cli/src/index.ts Registers the new update-profile command in the CLI.
packages/plugin-cli/src/commands/update-profile.ts Implements the new CLI command (manifest load, auth/session, output formatting, error handling).
packages/plugin-cli/src/commands/publish.ts Updates subsequent-publish warning to direct users to update-profile.
.changeset/big-hands-peel.md Declares a minor bump for @emdash-cms/plugin-cli and documents the new command.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +93 to +96
/** Authenticated client against the publisher's PDS. */
publisher: PublishingClient;
/** Publisher DID. Used to construct AT URIs for display/output. */
did: Did;
if (sibling !== null) {
throw new UpdateProfileError(
"POSSIBLE_RENAME",
`No profile at ${profileUri}, but the publisher already has a profile at slug "${sibling}". If you renamed the plugin in your manifest, that would orphan every release under "${sibling}" — revert the slug to "${sibling}" in emdash-plugin.jsonc, or publish the rename under the new slug as a fresh package (releases under "${sibling}" stay where they are).`,
json: {
type: "boolean",
description:
"Emit a single-line JSON object on stdout instead of human output. Success: {profile, diffs, written, cid?}. Failure: {error: {code, message}}. Human-readable progress goes to stderr.",
Comment on lines +357 to +377
/**
* Reroute consola to stderr so stdout stays clean for `--json` consumers.
* Returns a restore function. Mirrors the publish command's helper so the
* two share a contract for json-mode pipe consumers.
*/
function redirectConsolaToStderr(): () => void {
const previous = consola.options.reporters?.slice() ?? [];
consola.setReporters([
{
log(logObj) {
const level = logObj.type ?? "info";
const tag = logObj.tag ? `[${logObj.tag}] ` : "";
const args = logObj.args ?? [];
const message = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
process.stderr.write(`${level}: ${tag}${message}\n`);
},
},
]);
return () => {
consola.setReporters(previous);
};
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review. Most of the surface here is solid — dry-run-by-default, identity-field preservation, and the POSSIBLE_RENAME diagnostic all do the right thing. Two behavior issues worth addressing before this lands, both around the manifest-as-source-of-truth model.

diffs.push({ field, before, after: undefined });
}
continue;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent data loss when a CLI user removes an optional field from their manifest.

Flow:

  1. Publisher publishes a plugin with name: "My Plugin" in their manifest. The PDS profile record gets name: "My Plugin".
  2. Publisher edits the manifest, removes the name key (maybe by accident, maybe because they thought the field was optional and didn't realize it was already on the record).
  3. Publisher runs update-profile --yes.

profileUpdateInputFromManifest only sets input.name if profile.name !== undefined, so input.name stays unset → normaliseInput leaves out.name undefined → buildProfileCandidate here treats after === undefined && before !== undefined as "user cleared the field" and deletes name from the PDS record. Same for description and keywords.

Dry-run does mitigate this (the user sees - name in the diff before --yes), but the doc comment at the top of update-profile.ts doesn't warn that removing a manifest key clears the PDS field, and the model is the opposite of publish (where the existing profile wins for these fields — that's exactly what result.ignoredProfileFields warns about). The publish warning now points users at update-profile, so they're trained to expect the same semantics.

At minimum: document this prominently and have the dry-run output flag deletes louder (the current ~ vs - distinction in renderDiffLine is easy to miss). Better: require explicit opt-in to clear an optional field (e.g. --clear name), or treat "absent in manifest" as "don't touch on PDS" and add a separate --clear mechanism.

rkey: options.slug,
record: candidate,
skipValidation: true,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU between fetchExistingProfile and unsafePutRecord — concurrent writes silently overwrite.

We read the record on line 166, then write back on line 245 without passing a swapRecord precondition. If anything mutates the record between those calls (another update-profile invocation, a future admin UI flow, a manual PDS edit, even an aggregator-driven write if that ever exists), our putRecord silently overwrites it with our pre-modification view plus our diff applied — the other writer's changes vanish without warning.

Most realistic trigger: a publisher with a profile change pending in another shell, or pairing this CLI with manual PDS edits during the experimental phase.

com.atproto.repo.putRecord accepts swapRecord (CID-based optimistic concurrency); unsafePutRecord doesn't expose it today. Either thread swapRecord: existing.cid through (and add a STALE error code surfacing the conflict), or at minimum document that this command races with concurrent writers and is not safe to run while other edits are in flight.

`Candidate profile record did not match the lexicon after merge.`,
{ slug: options.slug, issues: candidateValidation },
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LEXICON_VALIDATION_FAILED error message blames the wrong party for valid-looking caller input.

Message says "a failure here genuinely indicates a record-shape regression in update-profile or in the lexicon itself." But validateInput only catches empty arrays, missing author names, and contacts-with-no-contact-info. The lexicon additionally enforces:

  • keywords: maxLength 5, each element maxLength 128 / 64 graphemes
  • authors: maxLength 32, name maxGraphemes 64, url maxLength 1024, url must parse as a generic URI
  • security: maxLength 8, url must parse, email maxLength 256
  • name: maxGraphemes 100, description: maxGraphemes 140
  • license: maxLength 256

A programmatic caller passing e.g. keywords: ["a", "b", "c", "d", "e", "f"] or authors: [{ name: "x", url: "not a url" }] would land at LEXICON_VALIDATION_FAILED with a misleading message that points at the implementation instead of the caller's input. The validation tree is dumped into detail.issues, but the top-level message is still wrong.

Either (a) extend validateInput to mirror the lexicon's structural rules so callers get INVALID_INPUT with a specific message, or (b) reword LEXICON_VALIDATION_FAILED to acknowledge it can also be caller input that bypassed validateInput.

// trying to catch is a publisher renaming their plugin; a real
// publisher with that situation will still get the message on retry.
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bare catch {} swallows auth/permission failures and degrades to a misleading PROFILE_NOT_FOUND.

If listRecords fails because the OAuth scope doesn't permit it, the session expired mid-flow, or the PDS returned 5xx, we silently return null and the caller throws PROFILE_NOT_FOUND with no hint that the rename-detection path didn't actually run. A publisher who just renamed their plugin will get a flat "no record" error and no idea their other profiles weren't even checked.

The comment justifies this for transient network failures, but ClientResponseError distinguishes auth/permission errors from transient ones (InvalidToken, AuthRequired, etc.). At minimum, re-throw on AuthRequired / InvalidToken so the user sees a real "re-login" message instead of PROFILE_NOT_FOUND.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 20, 2026

Review posted successfully with 4 line-anchored findings:

  1. MEDIUM — Silent data loss: removing optional fields from the manifest deletes them from the PDS without explicit user intent. Dry-run mitigates but the model inverts publish's "existing wins" semantics that users were just trained on.

  2. MEDIUM — TOCTOU between getRecord and unsafePutRecord: no swapRecord precondition means concurrent writes are silently lost.

  3. LOWLEXICON_VALIDATION_FAILED error message blames implementation/lexicon when the actual cause is often unvalidated caller input (keyword counts, URL formats, grapheme limits not covered by validateInput).

  4. LOWfindSiblingProfileSlug's bare catch {} swallows auth/permission errors and degrades to a misleading PROFILE_NOT_FOUND.

github run

"Profile" overloaded with the publisher's atproto profile
(app.bsky.actor.profile / src/profile.ts). The command operates on
com.emdashcms.experimental.package.profile — the per-package metadata
record — so "package" describes the user-facing surface without the
clash.

Renames the command, its api module, exported types
(PackageUpdateInput, UpdatePackageError, etc.), error codes
(PACKAGE_NOT_FOUND, PACKAGE_INVALID), and updates the publish warning.
The underlying record is still a profile record; docstrings keep that
term where it refers to the lexicon shape.
@ascorbic ascorbic changed the title feat(plugin-cli): add update-profile command feat(plugin-cli): add update-package command May 20, 2026
Drop UpdatePackageOptions.did — derive from publisher.did so the two
can't disagree (Copilot).

POSSIBLE_RENAME now lists every other package the publisher owns rather
than singling out an arbitrary one, so a publisher with multiple plugins
doesn't see a misleading rename pointer at an unrelated slug (Copilot).

Flip the optional-field policy: missing from manifest = preserve
existing value, matching `publish` semantics. Removing `description`
or `keywords` by accident no longer wipes the published value
(ask-bonk).

Add atproto swapRecord optimistic concurrency: PublishingClient gains
swapRecord on putRecord/unsafePutRecord; update-package threads
existing.cid through and surfaces concurrent edits as STALE_RECORD
instead of silently overwriting (ask-bonk).

Rethrow auth/permission errors from the sibling-scan path so an
expired token surfaces as itself rather than a misleading
PACKAGE_NOT_FOUND (ask-bonk).

Reword LEXICON_VALIDATION_FAILED to name caller-input length/grapheme
caps as the likely cause rather than blaming update-package (ask-bonk).

Update --json help text to match the actual output shape (Copilot).

Extract redirectConsolaToStderr into cli-output.ts, shared between
publish and update-package (Copilot).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Registry CLI: update-profile command (edit an existing profile without a new release)

2 participants