fix(setup): retain symlinked data roots during uninstall (#1821)
This commit is contained in:
1 parent
d3e9ee5505
commit
7e2f79ab45
3 files changed
+231
-5
No files matched your search
@@ -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-");
|
||||
|
||||
Reference in new issue
Block a user