fix(setup): retain symlinked data roots during uninstall (#1821)

This commit is contained in:
Yann Roberto authored and GitHub committed 2026-10-11 22:54:03 +08:00
1 parent d3e9ee5505
commit 7e2f79ab45
3 files changed
+231 -5

No files matched your search

+34 -4
View File
@@ -184,10 +184,7 @@ export const systemUninstallHost = (
removeClient: (client) => removeClient(client, fileSystem),
removeSkill: () =>
removeManagedSkills(home, fileSystem, environment, platform),
purgeData: async () => [
await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem),
await removeManagedPath(join(home, ".rea/state"), "state", fileSystem),
],
purgeData: () => purgeManagedData(join(home, ".rea"), fileSystem),
};
};
@@ -380,6 +377,39 @@ const isOwnedRegistration = (value: unknown, client: SetupClient): boolean => {
return isOwnedClientRegistrationCommand([command, ...args]);
};
const purgeManagedData = async (
root: string,
fileSystem: UninstallFileSystem,
): Promise<readonly UninstallItem[]> => {
const names = ["cache", "state"];
try {
const stats = await fileSystem.stat(root);
const linked = stats.isSymbolicLink();
if (linked || stats.isDirectory?.() !== true)
return names.map((name) =>
item(
name,
"retained",
`REA did not remove this item because its managed data root ${root} is ${linked ? "a symbolic link" : "not a directory"}. Verify the root before removing its data manually.`,
),
);
} catch (cause: unknown) {
return names.map((name) =>
isMissing(cause)
? item(name, "skipped", `${join(root, name)} does not exist.`)
: item(
name,
"failed",
`The managed data root ${root} could not be verified. Check its permissions, then rerun uninstall.`,
),
);
}
const items: UninstallItem[] = [];
for (const name of names)
items.push(await removeManagedPath(join(root, name), name, fileSystem));
return items;
};
const removeManagedPath = async (
path: string,
name: string,
@@ -1,4 +1,12 @@
import { access, readFile, writeFile } from "node:fs/promises";
import {
access,
lstat,
mkdir,
readFile,
realpath,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
@@ -78,6 +86,54 @@ const groups = [
],
},
];
cliTest(
"uninstall purge retains unrelated data behind a managed-root symlink",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-uninstall-root-link-");
const home = join(root, "home");
const unrelated = join(root, "unrelated-application");
await mkdir(home);
for (const name of ["cache", "state"]) {
await mkdir(join(unrelated, name), { recursive: true });
await writeFile(join(unrelated, name, "keep.txt"), `unrelated ${name}`);
}
const managedRoot = join(home, ".rea");
await symlink(
unrelated,
managedRoot,
process.platform === "win32" ? "junction" : "dir",
);
const result = await cli.run({
arguments: ["uninstall", "--purge-data", "--json"],
cwd: home,
environment: {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: join(root, "xdg-config"),
XDG_CACHE_HOME: join(root, "xdg-cache"),
},
timeoutMs: 20_000,
});
expect(result.exitCode, result.stderr).toBe(0);
expect.soft(result.json).toMatchObject({
status: "complete",
items: expect.arrayContaining([
expect.objectContaining({ name: "cache", status: "retained" }),
expect.objectContaining({ name: "state", status: "retained" }),
]),
});
expect((await lstat(managedRoot)).isSymbolicLink()).toBe(true);
expect(await realpath(managedRoot)).toBe(await realpath(unrelated));
for (const name of ["cache", "state"])
await expect
.soft(readFile(join(unrelated, name, "keep.txt"), "utf8"))
.resolves.toBe(`unrelated ${name}`);
},
);
for (const group of groups) {
cliTest(
`${group.name} lifecycle follows the selected roots`,
@@ -1341,6 +1341,146 @@ describe("Codex configuration comments", () => {
});
});
describe("managed data purge root validation", () => {
it.each(["existing", "dangling"] as const)(
"retains cache and state when the managed-root symlink is %s",
async (targetState) => {
const root = await createTestTempDirectory("rea-uninstall-data-root-");
roots.push(root);
const home = join(root, "home");
const outside = join(root, "unrelated-application");
await mkdir(home);
if (targetState === "existing")
for (const name of ["cache", "state"]) {
await mkdir(join(outside, name), { recursive: true });
await writeFile(join(outside, name, "keep.txt"), "unrelated data");
}
const managedRoot = join(home, ".rea");
await symlink(
outside,
managedRoot,
process.platform === "win32" ? "junction" : "dir",
);
const result = await runUninstall(
true,
systemUninstallHost(home, undefined, {}),
);
expect(result.status).toBe("complete");
expect((await lstat(managedRoot)).isSymbolicLink()).toBe(true);
for (const name of ["cache", "state"])
expect(result.items).toContainEqual(
expect.objectContaining({ name, status: "retained" }),
);
if (targetState === "existing")
for (const name of ["cache", "state"])
expect(await readFile(join(outside, name, "keep.txt"), "utf8")).toBe(
"unrelated data",
);
else
await expect(lstat(outside)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it("retains a managed data root that is not a directory", async () => {
const home = await createTestTempDirectory("rea-uninstall-data-file-");
roots.push(home);
const managedRoot = join(home, ".rea");
await writeFile(managedRoot, "unrelated file");
const result = await runUninstall(
true,
systemUninstallHost(home, undefined, {}),
);
expect(result.status).toBe("complete");
for (const name of ["cache", "state"])
expect(result.items).toContainEqual(
expect.objectContaining({ name, status: "retained" }),
);
expect(await readFile(managedRoot, "utf8")).toBe("unrelated file");
});
it("purges ordinary managed data through a legitimate home directory link", async () => {
const root = await createTestTempDirectory("rea-uninstall-home-link-");
roots.push(root);
const home = join(root, "actual-home");
const linkedHome = join(root, "linked-home");
const managedRoot = join(home, ".rea");
for (const name of ["cache", "state"]) {
await mkdir(join(managedRoot, name), { recursive: true });
await writeFile(join(managedRoot, name, "data.txt"), "managed data");
}
await writeFile(join(managedRoot, "keep.txt"), "other data");
await symlink(
home,
linkedHome,
process.platform === "win32" ? "junction" : "dir",
);
const result = await runUninstall(
true,
systemUninstallHost(linkedHome, undefined, {}),
);
expect(result.status).toBe("complete");
for (const name of ["cache", "state"]) {
expect(result.items).toContainEqual(
expect.objectContaining({ name, status: "removed" }),
);
await expect(lstat(join(managedRoot, name))).rejects.toMatchObject({
code: "ENOENT",
});
}
expect((await lstat(linkedHome)).isSymbolicLink()).toBe(true);
expect(await readFile(join(managedRoot, "keep.txt"), "utf8")).toBe(
"other data",
);
});
it("reports root inspection failure without removing managed data", async () => {
const home = await createTestTempDirectory("rea-uninstall-data-denied-");
roots.push(home);
const managedRoot = join(home, ".rea");
for (const name of ["cache", "state"]) {
await mkdir(join(managedRoot, name), { recursive: true });
await writeFile(join(managedRoot, name, "keep.txt"), "managed data");
}
const remove = vi.fn(testFileSystem.remove);
const result = await runUninstall(
true,
systemUninstallHost(
home,
{
...testFileSystem,
stat: (path) =>
path === managedRoot
? Promise.reject(
Object.assign(new Error("root inspection denied"), {
code: "EACCES",
}),
)
: lstat(path),
remove,
},
{},
),
);
expect(result.status).toBe("failed");
expect(remove).not.toHaveBeenCalled();
for (const name of ["cache", "state"]) {
expect(result.items).toContainEqual(
expect.objectContaining({ name, status: "failed" }),
);
expect(await readFile(join(managedRoot, name, "keep.txt"), "utf8")).toBe(
"managed data",
);
}
});
});
describe("client configuration filesystem removal", () => {
it("uninstalls only owned entries and refuses purge symlinks", async () => {
const home = await createTestTempDirectory("rea-uninstall-");