From 7292a2545113ac35bd06f9dc8be4ff7e802e0307 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 16:12:50 +0100 Subject: [PATCH] fix(plugin-cli): preserve profile extensions on publish --- .changeset/preserve-profile-extensions.md | 5 +++ packages/plugin-cli/src/publish/api.ts | 12 ++++--- packages/plugin-cli/tests/publish.test.ts | 33 +++++++++++++++++++ .../plugin-cli/tests/update-package.test.ts | 15 ++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .changeset/preserve-profile-extensions.md diff --git a/.changeset/preserve-profile-extensions.md b/.changeset/preserve-profile-extensions.md new file mode 100644 index 0000000000..e978e5184b --- /dev/null +++ b/.changeset/preserve-profile-extensions.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-cli": patch +--- + +Fixes subsequent `emdash-plugin publish` commands removing existing package-profile extensions. diff --git a/packages/plugin-cli/src/publish/api.ts b/packages/plugin-cli/src/publish/api.ts index 1a4f73b52e..1d478a2c1e 100644 --- a/packages/plugin-cli/src/publish/api.ts +++ b/packages/plugin-cli/src/publish/api.ts @@ -277,6 +277,7 @@ interface PackageProfileRecordShape { description?: string; keywords?: string[]; sections?: Record; + extensions?: Record; } /** An image artifact embedded in a release (`release.json#artifact`). */ @@ -709,11 +710,9 @@ async function getRecordOrNull( * update rather than overwriting an invalid record with a slightly-different * invalid record). * - * Unknown / extra fields on the existing record are intentionally preserved - * verbatim via the spread. If they violate the lexicon, the local - * `validateLocally` pass before `applyWrites` will reject the candidate - * with a `LEXICON_VALIDATION_FAILED` error rather than letting an invalid - * record propagate to the registry. + * The canonical profile fields are re-emitted explicitly. A valid keyed + * `extensions` map is preserved verbatim; other unknown top-level fields + * remain excluded from the canonical update path. */ function stampLastUpdated(existingValue: unknown): PackageProfileRecordShape | null { if (!existingValue || typeof existingValue !== "object") return null; @@ -745,6 +744,9 @@ function stampLastUpdated(existingValue: unknown): PackageProfileRecordShape | n if (typeof v.description === "string") candidate.description = v.description; if (Array.isArray(v.keywords)) candidate.keywords = v.keywords; if (v.sections && typeof v.sections === "object") candidate.sections = v.sections; + if (v.extensions && typeof v.extensions === "object" && !Array.isArray(v.extensions)) { + candidate.extensions = v.extensions; + } return candidate as unknown as PackageProfileRecordShape; } diff --git a/packages/plugin-cli/tests/publish.test.ts b/packages/plugin-cli/tests/publish.test.ts index 984d2d89ba..58e3ff36a6 100644 --- a/packages/plugin-cli/tests/publish.test.ts +++ b/packages/plugin-cli/tests/publish.test.ts @@ -254,6 +254,39 @@ describe("publishRelease", () => { expect(profileOp?.$type).toBe("com.atproto.repo.applyWrites#update"); }); + it("preserves profile extensions when publishing a later release", async () => { + const pds = new MockPds({ did: TEST_DID }); + const extensions = { + [NSID.packageProfileExtension]: { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/test-plugin", + releasePolicy: { + requireProvenance: true, + confirmation: "always", + approvers: ["did:plc:approver"], + }, + }, + "com.example.unknown.extension": { + $type: "com.example.unknown.extension", + value: { preserve: ["this", "exactly"] }, + }, + }; + pds.seedRecord(NSID.packageProfile, "test-plugin", { + ...wellShapedProfile, + extensions, + }); + + await publishRelease( + buildOptions(pds, { + manifest: buildManifest({ version: "1.1.0" }), + url: "https://example.com/test-plugin-1.1.0.tar.gz", + }), + ); + + const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`); + expect((profile!.value as { extensions?: unknown }).extensions).toEqual(extensions); + }); + it("does not touch a malformed existing profile (just writes the release)", async () => { const pds = new MockPds({ did: TEST_DID }); // Existing profile is missing required fields. We refuse to update diff --git a/packages/plugin-cli/tests/update-package.test.ts b/packages/plugin-cli/tests/update-package.test.ts index d40efa9d64..31932c0570 100644 --- a/packages/plugin-cli/tests/update-package.test.ts +++ b/packages/plugin-cli/tests/update-package.test.ts @@ -224,10 +224,22 @@ describe("updatePackage", () => { expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0); }); - it("preserves unknown forward-compatible fields on the existing record", async () => { + it("preserves extensions and unknown forward-compatible fields on the existing record", async () => { const pds = new MockPds({ did: TEST_DID }); + const extensions = { + [NSID.packageProfileExtension]: { + $type: NSID.packageProfileExtension, + repository: "https://github.com/example/test-plugin", + releasePolicy: { requireProvenance: true }, + }, + "com.example.unknown.extension": { + $type: "com.example.unknown.extension", + value: { preserve: ["this", "exactly"] }, + }, + }; seedProfile(pds, { sections: { description: "long-form text" }, + extensions, someFutureField: { nested: true }, }); @@ -242,6 +254,7 @@ describe("updatePackage", () => { const stored = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/${SLUG}`); const value = stored!.value as Record; expect(value.sections).toEqual({ description: "long-form text" }); + expect(value.extensions).toEqual(extensions); expect(value.someFutureField).toEqual({ nested: true }); }); });