mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 09:55:46 +00:00
fix(uninstall): preserve the configuration as it was before uninstall edits it
This commit is contained in:
1 parent
f26973a529
commit
2dfc2d0f7c
11 files changed
+352
-82
No files matched your search
@@ -59,10 +59,11 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
|
||||
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||
create backups, use atomic replacement, and verify their result. Setup and
|
||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
||||
If that snapshot differs from the current configuration, setup previews and
|
||||
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
|
||||
Both snapshots remain available; a failed rollback reports the current recovery
|
||||
copy's path and the restore failure.
|
||||
If that snapshot differs from the current configuration, setup and uninstall
|
||||
preserve a verified `.rea.backup.<sha256>.recovery` copy of the current bytes
|
||||
before editing; setup also previews it. Both snapshots remain available; a
|
||||
failed update, readback, or rollback names the backup path that holds the bytes
|
||||
from just before that edit.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE`, `REA_MCP_OUTPUT_SCHEMA_PROFILE`, and
|
||||
`REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
|
||||
@@ -43,7 +43,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
31,src/application/ReferenceSourceImportTypes.ts:limit,Import reports terminal limit failure,No reachable constructor found,Declared state may be unreachable; false verification risk,Remove unreachable top-level limit variant; per-entry limit guidance remains caller-visible.,removed,"verified by typecheck, exhaustive projection test, and source search"
|
||||
32,src/application/Setup.ts:configureDetectedClients(backup),Agent configuration backup fails,"{clientName} configuration backup verification failed; no successful configuration was reported.",Exposes machine client key and obscures no-write guarantee,"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.",implemented,focused assertions passed
|
||||
33,src/application/Setup.ts:configureDetectedClients(write),Agent configuration write fails,"{clientName} configuration write verification failed; no successful configuration was reported.",Exposes machine client key and gives no recovery,"Agent configuration could not be updated. Check file permissions, then rerun setup.",implemented,focused assertions passed
|
||||
34,src/application/Setup.ts:configureDetectedClients(readback),Written agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",implemented,focused assertions passed
|
||||
34,src/application/Setup.ts:configureDetectedClients(readback),Written agent configuration cannot be verified,"{clientName} configuration readback verification failed; no successful configuration was reported.",Exposes machine client key and omits backup recovery,"Agent configuration could not be verified after writing. The original was restored and its backup at {backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup. Without a backup: Agent configuration could not be verified. Repair the configuration file, then rerun setup.",implemented,focused assertions passed
|
||||
35,src/application/Setup.ts:installSkill,Skill install or semantic readback fails,Agent skill installation or readback failed.,No useful recovery action,"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",implemented,focused assertions passed
|
||||
36,src/main.ts:serveStdio.onerror,Established MCP client connection reports an error,MCP stdio transport error,Exposes transport implementation and gives no recovery,REA lost its MCP client connection. Restart REA from your MCP client.,implemented,verified by injected transport onerror runtime test
|
||||
37,src/main.ts:serveStdio throw,MCP connection cannot be created,Failed to start MCP stdio transport,Exposes transport implementation and gives no recovery,REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.,implemented,verified by injected transport startup-throw runtime test
|
||||
@@ -55,7 +55,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement
|
||||
43,src/application/Uninstall.ts:removeClient parse,Agent configuration is malformed,Malformed configuration was preserved without mutation.,Does not name required repair format or retry,"Configuration is not valid JSON/TOML and was not changed. Repair it, then rerun uninstall.",implemented,focused JSON assertion passed; TOML path uses same branch
|
||||
44,src/application/Uninstall.ts:removeClient backup,Configuration backup copy fails,Configuration backup failed; no mutation was attempted.,No recovery step,"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",implemented,verified by injected filesystem backup failure test
|
||||
45,src/application/Uninstall.ts:removeClient update,Write or semantic readback fails,Configuration update failed; the original was restored and {backupPath} was retained.,Exposes local path and lacks clear next sequence,"Report whether the original was restored; if restoration also fails, direct the user to the retained backup.",implemented,verified by injected update and restoration failure tests
|
||||
45.5,src/application/Uninstall.ts:removeClient restore,Configuration update fails and restoring the original also fails,"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.","High risk; persistent configuration may be damaged, but retained backup is authoritative",Keep new recovery copy,implemented,verified by injected restoration failure test
|
||||
45.5,src/application/Uninstall.ts:removeClient restore,Configuration update fails and restoring the original also fails,"Configuration could not be updated or restored. Restore the backup at {backupPath} manually, then rerun uninstall.","High risk; persistent configuration may be damaged, but retained backup is authoritative",Keep new recovery copy,implemented,verified by injected restoration failure test
|
||||
46,src/application/Uninstall.ts:removeManagedPath symlink,Managed cache/state/skill path is a symbolic link,Refused to follow symlink {path}.,Exposes local path and does not explain manual safety check,"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.",implemented,focused assertion passed
|
||||
47,src/application/Uninstall.ts:removeManagedPath failure,Managed item removal fails,{path} could not be removed.,Exposes local path and gives no recovery,"This item could not be removed. Check file permissions, then rerun uninstall.",implemented,verified by injected managed-path removal failure test
|
||||
48,src/application/DoctorDiagnostics.ts:node,Runtime version is outside supported ranges,"Install Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+.",Low risk; exact dependency recovery,Keep current copy,unchanged,verified by doctor unit test
|
||||
|
||||
|
@@ -360,7 +360,7 @@ describe("setup workflow action selection", () => {
|
||||
],
|
||||
[
|
||||
"readback",
|
||||
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.",
|
||||
"Agent configuration could not be verified. Repair the configuration file, then rerun setup.",
|
||||
],
|
||||
] as const)("explains %s configuration recovery", async (reason, message) => {
|
||||
const host = new FakeSetupHost();
|
||||
@@ -375,6 +375,25 @@ describe("setup workflow action selection", () => {
|
||||
expect(result.remediation).toBe(message);
|
||||
expect(result.remediation).not.toContain("internal_client_key");
|
||||
});
|
||||
|
||||
it("names the recovery backup that holds the restored original after a readback failure", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "cursor", configPath: "/agent.json" }];
|
||||
const backupPath = `/agent.json.rea.backup.${"a".repeat(64)}.recovery`;
|
||||
host.clientResults.set("cursor", {
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
backupPath,
|
||||
});
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["cursor"] },
|
||||
host,
|
||||
);
|
||||
expect(result.remediation).toBe(
|
||||
`Agent configuration could not be verified after writing. The original was restored and its backup at ${backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MSIX Claude Desktop setup", () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import {
|
||||
preserveRecoveryBackup,
|
||||
preserveCurrentRecoveryBackup,
|
||||
selectCurrentRecoveryBackup,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
||||
@@ -247,27 +247,15 @@ const persistClientConfiguration = async ({
|
||||
};
|
||||
if (target.value) return { status: "failed", reason: "target_changed" };
|
||||
}
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
let recoveryBackupPath: string | undefined;
|
||||
if (backupPath !== undefined && original !== undefined) {
|
||||
const selection = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (!selection.ok) return { status: "failed", reason: "backup" };
|
||||
if (
|
||||
!(await preserveRecoveryBackup(
|
||||
transactionPath,
|
||||
selection.value.backupPath,
|
||||
))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
const verified = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = verified.value.backupPath;
|
||||
if (original !== undefined) {
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
transactionPath,
|
||||
original,
|
||||
);
|
||||
if (!preserved.ok) return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = preserved.value;
|
||||
}
|
||||
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
||||
// otherwise conflict with it.
|
||||
@@ -664,7 +652,11 @@ const rollbackOrReadbackFailure = async (
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const rollback = await restoreConfig(targetPath, original);
|
||||
return rollback.ok
|
||||
? { status: "failed", reason: "readback" }
|
||||
? {
|
||||
status: "failed",
|
||||
reason: "readback",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
|
||||
@@ -9,21 +9,26 @@ import type { SetupClient } from "./SupportedClients.js";
|
||||
export const failedConfigurationMessage = (
|
||||
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
|
||||
): string => {
|
||||
if (result.reason === "target_changed")
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
if (result.reason === "target_unavailable")
|
||||
return `The client configuration read target could not be established because ${result.failure.path} could not be inspected: ${result.failure.detail}. Repair access to that path, then rerun setup.`;
|
||||
if (result.reason === "path")
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
if (result.reason === "backup")
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "write")
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "rollback")
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
|
||||
switch (result.reason) {
|
||||
case "target_changed":
|
||||
return "The client began reading a different configuration file after the plan was shown, so no change was made. Rerun setup to review the new target.";
|
||||
case "target_unavailable":
|
||||
return `The client configuration read target could not be established because ${result.failure.path} could not be inspected: ${result.failure.detail}. Repair access to that path, then rerun setup.`;
|
||||
case "path":
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
case "backup":
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
case "write":
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
case "rollback":
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
case "readback":
|
||||
return result.backupPath === undefined
|
||||
? "Agent configuration could not be verified. Repair the configuration file, then rerun setup."
|
||||
: `Agent configuration could not be verified after writing. The original was restored and its backup at ${result.backupPath} was retained. Repair the configuration file or restore that backup, then rerun setup.`;
|
||||
}
|
||||
};
|
||||
|
||||
/** Configure every detected agent and report the first failed transaction. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
readRegularFile,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
|
||||
export interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
@@ -28,15 +29,19 @@ const errorDetail = (cause: unknown): string =>
|
||||
export const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string | Buffer,
|
||||
resources?: ArtifactResourceScope,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const read = (path: string) =>
|
||||
readRegularFile(path, {
|
||||
symlinks: "reject",
|
||||
...(resources === undefined ? {} : { resources }),
|
||||
});
|
||||
const expected = Buffer.isBuffer(original)
|
||||
? original
|
||||
: Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
firstBackup = await read(firstBackupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
@@ -55,7 +60,7 @@ export const selectCurrentRecoveryBackup = async (
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
recovery = await read(recoveryPath);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
@@ -74,15 +79,61 @@ export const selectCurrentRecoveryBackup = async (
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
/** Preserve a snapshot without replacing an earlier recovery file. */
|
||||
export const preserveRecoveryBackup = async (
|
||||
export interface RecoveryBackupPreservationFailure {
|
||||
readonly kind: RecoveryBackupSelectionFailure["kind"] | "copy" | "unverified";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const copyExclusive = (source: string, destination: string): Promise<void> =>
|
||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
|
||||
export interface RecoveryBackupPreservationOptions {
|
||||
/** Copy without replacing an existing destination; reject one with EEXIST. */
|
||||
readonly copy?: (source: string, destination: string) => Promise<void>;
|
||||
/** Scope that retains a backup read whose handle cleanup failed. */
|
||||
readonly resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a transaction's original bytes before it edits the file: keep a
|
||||
* matching first backup, otherwise copy them to the content-addressed recovery
|
||||
* path without replacing an earlier file, then verify the selected snapshot.
|
||||
*/
|
||||
export const preserveCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<boolean> => {
|
||||
original: string | Buffer,
|
||||
{ copy = copyExclusive, resources }: RecoveryBackupPreservationOptions = {},
|
||||
): Promise<Result<string, RecoveryBackupPreservationFailure>> => {
|
||||
const selection = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
resources,
|
||||
);
|
||||
if (!selection.ok) return selection;
|
||||
const { backupPath } = selection.value;
|
||||
try {
|
||||
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
||||
return true;
|
||||
await copy(source, backupPath);
|
||||
} catch (cause: unknown) {
|
||||
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST"))
|
||||
return err({
|
||||
kind: "copy",
|
||||
path: backupPath,
|
||||
detail: `The recovery backup could not be written: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
const verified = await selectCurrentRecoveryBackup(
|
||||
firstBackupPath,
|
||||
original,
|
||||
resources,
|
||||
);
|
||||
if (!verified.ok) return verified;
|
||||
if (verified.value.backupPath !== backupPath || !verified.value.present)
|
||||
return err({
|
||||
kind: "unverified",
|
||||
path: backupPath,
|
||||
detail: `The recovery backup does not match the configuration it preserves: ${backupPath}`,
|
||||
});
|
||||
return ok(backupPath);
|
||||
};
|
||||
@@ -35,12 +35,13 @@ export type ClientConfigurationResult =
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason:
|
||||
| "path"
|
||||
| "target_changed"
|
||||
| "backup"
|
||||
| "write"
|
||||
| "readback";
|
||||
readonly reason: "path" | "target_changed" | "backup" | "write";
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "readback";
|
||||
/** Snapshot of the restored original, when this transaction made one. */
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
|
||||
@@ -26,6 +26,10 @@ import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.j
|
||||
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
preserveCurrentRecoveryBackup,
|
||||
type RecoveryBackupPreservationOptions,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import {
|
||||
InvalidUtf8FileError,
|
||||
RegularFileCleanupFailure,
|
||||
@@ -264,7 +268,9 @@ export const systemUninstallHost = (
|
||||
: item(client.name, "failed", blocker);
|
||||
},
|
||||
removeClient: (client) =>
|
||||
removeClient(client, fileSystem, () => clientWriteBlocker(client)),
|
||||
removeClient(client, fileSystem, resources, () =>
|
||||
clientWriteBlocker(client),
|
||||
),
|
||||
inspectSkill: async () => {
|
||||
for (const destination of skillDestinations(
|
||||
home,
|
||||
@@ -435,9 +441,42 @@ const clientRegistrationRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Preserve the bytes uninstall is about to edit and return the backup holding them. */
|
||||
const backupClientConfiguration = async (
|
||||
client: SetupClient,
|
||||
transactionPath: string,
|
||||
original: string,
|
||||
options: RecoveryBackupPreservationOptions,
|
||||
): Promise<string | UninstallItem> => {
|
||||
const failed = (detail: string) =>
|
||||
item(
|
||||
client.name,
|
||||
"failed",
|
||||
`Configuration could not be backed up, so no change was made. ${detail}`,
|
||||
);
|
||||
try {
|
||||
const preserved = await preserveCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
transactionPath,
|
||||
original,
|
||||
options,
|
||||
);
|
||||
if (preserved.ok) return preserved.value;
|
||||
if (preserved.error.kind === "occupied")
|
||||
return failed(
|
||||
`${preserved.error.detail}. Move that file aside, then rerun uninstall.`,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
// The resource scope retains a backup handle whose close failed.
|
||||
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
||||
}
|
||||
return failed("Check file permissions, then rerun uninstall.");
|
||||
};
|
||||
|
||||
const removeClient = async (
|
||||
client: SetupClient,
|
||||
fileSystem: UninstallFileSystem,
|
||||
resources: ArtifactResourceScope,
|
||||
writeBlocker?: () => string | undefined,
|
||||
): Promise<UninstallItem> => {
|
||||
const read = await readClientConfiguration(client, fileSystem);
|
||||
@@ -479,17 +518,16 @@ const removeClient = async (
|
||||
...(removeNative ? [clientServerPath(parsed, name)] : []),
|
||||
...(legacyPath === undefined ? [] : [legacyPath]),
|
||||
];
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await fileSystem.copy(transactionPath, backupPath);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST"))
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
);
|
||||
}
|
||||
const backupPath = await backupClientConfiguration(
|
||||
client,
|
||||
transactionPath,
|
||||
original,
|
||||
{
|
||||
copy: (source, destination) => fileSystem.copy(source, destination),
|
||||
resources,
|
||||
},
|
||||
);
|
||||
if (typeof backupPath !== "string") return backupPath;
|
||||
try {
|
||||
await fileSystem.writeText(
|
||||
transactionPath,
|
||||
@@ -526,13 +564,13 @@ const removeClient = async (
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
||||
`Configuration could not be updated or restored. Restore the backup at ${backupPath} manually, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
||||
`Configuration could not be updated. The original was restored and its backup at ${backupPath} was retained. Repair the configuration or restore that backup, then rerun uninstall.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
lstat,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -9,9 +18,128 @@ import {
|
||||
configureClientConfiguration,
|
||||
inspectClientConfiguration,
|
||||
} from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import {
|
||||
systemUninstallHost,
|
||||
type UninstallFileSystem,
|
||||
} from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("uninstall backups after edits made since setup", () => {
|
||||
const editedSinceSetup = async (prefix: string) => {
|
||||
const root = await createTestTempDirectory(prefix);
|
||||
const configPath = join(root, "config.json");
|
||||
const client = { name: "cursor", format: "json", configPath } as const;
|
||||
const beforeSetup = '{"keep":"before setup"}\n';
|
||||
await writeFile(configPath, beforeSetup);
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
||||
).toMatchObject({ status: "configured" });
|
||||
const configured = JSON.parse(await readFile(configPath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const beforeUninstall = `${JSON.stringify({ ...configured, keep: "edited after setup" }, null, 2)}\n`;
|
||||
await writeFile(configPath, beforeUninstall);
|
||||
const recoveryPath = `${configPath}.rea.backup.${createHash("sha256")
|
||||
.update(beforeUninstall, "utf8")
|
||||
.digest("hex")}.recovery`;
|
||||
return {
|
||||
root,
|
||||
configPath,
|
||||
client,
|
||||
beforeSetup,
|
||||
beforeUninstall,
|
||||
recoveryPath,
|
||||
};
|
||||
};
|
||||
|
||||
const fileSystem = (failWrites: "update" | "always"): UninstallFileSystem => {
|
||||
let writes = 0;
|
||||
return {
|
||||
readText: (path) => readFile(path, "utf8"),
|
||||
copy: (source, destination) =>
|
||||
copyFile(source, destination, fsConstants.COPYFILE_EXCL),
|
||||
writeText: async (path, contents) => {
|
||||
writes += 1;
|
||||
if (failWrites === "always" || writes === 1)
|
||||
throw new Error("write failed");
|
||||
await writeFile(path, contents);
|
||||
},
|
||||
stat: (path) => lstat(path),
|
||||
realpath: (path) => realpath(path),
|
||||
remove: (path) => rm(path, { recursive: true }),
|
||||
};
|
||||
};
|
||||
|
||||
it("preserves the pre-uninstall bytes when removal succeeds", async () => {
|
||||
const {
|
||||
root,
|
||||
configPath,
|
||||
client,
|
||||
beforeSetup,
|
||||
beforeUninstall,
|
||||
recoveryPath,
|
||||
} = await editedSinceSetup("rea-uninstall-current-backup-");
|
||||
expect((await systemUninstallHost(root).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
|
||||
beforeSetup,
|
||||
);
|
||||
expect(await readFile(recoveryPath, "utf8")).toBe(beforeUninstall);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"update",
|
||||
(path: string) =>
|
||||
`Configuration could not be updated. The original was restored and its backup at ${path} was retained. Repair the configuration or restore that backup, then rerun uninstall.`,
|
||||
],
|
||||
[
|
||||
"always",
|
||||
(path: string) =>
|
||||
`Configuration could not be updated or restored. Restore the backup at ${path} manually, then rerun uninstall.`,
|
||||
],
|
||||
] as const)(
|
||||
"names the backup holding the pre-uninstall bytes when %s writes fail",
|
||||
async (failWrites, message) => {
|
||||
const {
|
||||
root,
|
||||
configPath,
|
||||
client,
|
||||
beforeSetup,
|
||||
beforeUninstall,
|
||||
recoveryPath,
|
||||
} = await editedSinceSetup("rea-uninstall-current-backup-failure-");
|
||||
expect(
|
||||
await systemUninstallHost(root, fileSystem(failWrites)).removeClient(
|
||||
client,
|
||||
),
|
||||
).toEqual({
|
||||
name: "cursor",
|
||||
status: "failed",
|
||||
detail: message(recoveryPath),
|
||||
});
|
||||
expect(await readFile(configPath, "utf8")).toBe(beforeUninstall);
|
||||
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(
|
||||
beforeSetup,
|
||||
);
|
||||
expect(await readFile(recoveryPath, "utf8")).toBe(beforeUninstall);
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks removal when the content-addressed backup holds other data", async () => {
|
||||
const { root, configPath, client, beforeUninstall, recoveryPath } =
|
||||
await editedSinceSetup("rea-uninstall-current-backup-occupied-");
|
||||
await writeFile(recoveryPath, "occupied by other data");
|
||||
const result = await systemUninstallHost(root).removeClient(client);
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.detail).toContain(recoveryPath);
|
||||
expect(await readFile(configPath, "utf8")).toBe(beforeUninstall);
|
||||
expect(await readFile(recoveryPath, "utf8")).toBe("occupied by other data");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a literal UTF-8 replacement character without changing its value", async () => {
|
||||
const root = await createTestTempDirectory("rea-client-config-utf8-");
|
||||
const configPath = join(root, "config.json");
|
||||
|
||||
@@ -1863,24 +1863,29 @@ describe("client configuration filesystem failure reporting", () => {
|
||||
it.each([
|
||||
[
|
||||
"read",
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
() =>
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
],
|
||||
[
|
||||
"backup",
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
() =>
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
],
|
||||
[
|
||||
"update",
|
||||
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall.",
|
||||
(config: string) =>
|
||||
`Configuration could not be updated. The original was restored and its backup at ${config}.rea.backup was retained. Repair the configuration or restore that backup, then rerun uninstall.`,
|
||||
],
|
||||
[
|
||||
"restore",
|
||||
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall.",
|
||||
(config: string) =>
|
||||
`Configuration could not be updated or restored. Restore the backup at ${config}.rea.backup manually, then rerun uninstall.`,
|
||||
],
|
||||
] as const)(
|
||||
"reports an actionable client %s failure",
|
||||
async (failure, detail) => {
|
||||
async (failure, expectedDetail) => {
|
||||
const { home, config } = await uninstallFixture();
|
||||
const detail = expectedDetail(config);
|
||||
let writes = 0;
|
||||
const fileSystem: UninstallFileSystem = {
|
||||
...testFileSystem,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../../../src/application/SetupHost.js";
|
||||
import { RegularFileCleanupFailure } from "../../../src/application/RegularFileRead.js";
|
||||
import { configureDetectedClients } from "../../../src/application/SetupClients.js";
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -119,6 +120,35 @@ it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fa
|
||||
}
|
||||
});
|
||||
|
||||
it("stops uninstall and retains the backup read owner when its close fails", async () => {
|
||||
const home = await createTestTempDirectory("rea-uninstall-backup-close-");
|
||||
const path = join(home, "client.json");
|
||||
const client = { name: "cursor", format: "json", configPath: path } as const;
|
||||
await writeFile(path, '{"keep":"before setup"}\n');
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, ["rea", "mcp"]),
|
||||
).toMatchObject({ status: "configured" });
|
||||
const configured = await readFile(path, "utf8");
|
||||
const { closeAttempts, release } = holdReadHandle(
|
||||
`${path}.rea.backup`,
|
||||
"held backup handle",
|
||||
);
|
||||
const host = systemUninstallHost(home, undefined, { HOME: home }, "linux");
|
||||
try {
|
||||
expect(await host.removeClient(client)).toEqual({
|
||||
name: "cursor",
|
||||
status: "failed",
|
||||
detail:
|
||||
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.",
|
||||
});
|
||||
expect(await readFile(path, "utf8")).toBe(configured);
|
||||
expect(closeAttempts).toHaveLength(2);
|
||||
await expectCloseReclaimsHandle(host, closeAttempts, release);
|
||||
} finally {
|
||||
await cleanupHeldHandle(host, release);
|
||||
}
|
||||
});
|
||||
|
||||
describe("ZCode fallback cleanup ownership", () => {
|
||||
it.each(["preflight", "configure"])(
|
||||
"retains a failed ZCode fallback close during %s until host cleanup",
|
||||
|
||||
Reference in new issue
Block a user