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
5 changes: 5 additions & 0 deletions .changeset/preserve-profile-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/plugin-cli": patch
---

Fixes subsequent `emdash-plugin publish` commands removing existing package-profile extensions.
12 changes: 7 additions & 5 deletions packages/plugin-cli/src/publish/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ interface PackageProfileRecordShape {
description?: string;
keywords?: string[];
sections?: Record<string, string>;
extensions?: Record<string, unknown>;
}

/** An image artifact embedded in a release (`release.json#artifact`). */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
33 changes: 33 additions & 0 deletions packages/plugin-cli/tests/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion packages/plugin-cli/tests/update-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});

Expand All @@ -242,6 +254,7 @@ describe("updatePackage", () => {
const stored = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/${SLUG}`);
const value = stored!.value as Record<string, unknown>;
expect(value.sections).toEqual({ description: "long-form text" });
expect(value.extensions).toEqual(extensions);
expect(value.someFutureField).toEqual({ nested: true });
});
});
Expand Down
Loading