fix(setup): verify published configuration content during uninstall

Require publication readback in the owning check so in-place edits are detected and preserved with the original backup.
This commit is contained in:
morluto
2026-10-27 06:43:26 +08:00
parent 82fd223638
commit 030fa886f9
4 changed files with 43 additions and 9 deletions
@@ -169,12 +169,12 @@ export class ClientConfigurationPublication {
return outcome.value;
}
async assertCurrent(readback?: string): Promise<void> {
async assertCurrent(readback: string): Promise<void> {
const owned = await this.owner.handle.stat();
const named = await lstat(this.path);
if (
owned.ino === 0 ||
(readback !== undefined && readback !== this.contents) ||
readback !== this.contents ||
named.isSymbolicLink() ||
!sameRegularFileState(owned, named)
)
+3 -5
View File
@@ -963,11 +963,9 @@ const removeClient = async (
try {
return await publication.verify(async () => {
try {
const readback = parseClientConfiguration(
await fileSystem.readText(publication.path),
client.format,
);
await publication.assertCurrent();
const text = await fileSystem.readText(publication.path);
await publication.assertCurrent(text);
const readback = parseClientConfiguration(text, client.format);
if (
(removeNative && Object.hasOwn(readback.servers, name)) ||
(readback.dialect === "dsh" &&
@@ -55,7 +55,7 @@ it
{ requestedPath, state },
);
await publication.verify(async () => {
await publication.assertCurrent();
await publication.assertCurrent(await readFile(publication.path, "utf8"));
expect(await readFile(selected, "utf8")).toBe("updated");
expect(await readFile(join(second, "config.json"), "utf8")).toBe(
"original",
@@ -629,6 +629,40 @@ it.each(["symlink", "regular", "valid-readback"] as const)(
},
);
it("uninstall retains concurrent edits to the published inode and reports failure", async () => {
const root = await createTestTempDirectory("rea-uninstall-inode-edit-");
const path = join(root, "client.json");
const fs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const original =
'{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}},"keep":"original"}\n';
const edited = '{"keep":"concurrent edit"}\n';
await fs.writeFile(path, original);
state.failReadback = false;
state.afterPublication = async (publishedPath) => {
state.afterPublication = undefined;
const before = await fs.stat(publishedPath);
await fs.writeFile(publishedPath, edited);
expect((await fs.stat(publishedPath)).ino).toBe(before.ino);
};
const host = systemUninstallHost(root);
try {
const result = await host.removeClient({
name: "cursor",
configPath: path,
format: "json",
});
expect(result.status).toBe("failed");
expect(result.detail).not.toContain("The original was restored");
expect(await fs.readFile(path, "utf8")).toBe(edited);
expect(await fs.readFile(`${path}.rea.backup`, "utf8")).toBe(original);
} finally {
await host.close?.();
}
});
it("uninstall retains both readback and publication owners until cleanup retry", async () => {
const root = await createTestTempDirectory("rea-uninstall-two-close-owners-");
const path = join(root, "client.json");
@@ -1394,7 +1428,9 @@ it("publication keeps its selected canonical parent when a directory symlink is
"{}",
original,
);
await publication.verify(() => publication.assertCurrent());
await publication.verify(async () =>
publication.assertCurrent(await fs.readFile(publication.path, "utf8")),
);
expect(await fs.readFile(join(selected, "client.json"), "utf8")).toBe("{}");
expect(await fs.readFile(join(foreign, "client.json"), "utf8")).toBe(
unrelated,