test(package): cover config symlink lifecycle

This commit is contained in:
N0zoM1z0 2026-07-14 21:37:36 +00:00
parent df87544a5d
commit d1ef7a5439
1 changed files with 92 additions and 3 deletions

View File

@ -1,10 +1,12 @@
import { execFile } from "node:child_process";
import {
chmod,
lstat,
mkdtemp,
mkdir,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@ -82,15 +84,23 @@ try {
chmod(npx, 0o755),
]);
const claudeDir = join(home, "Library/Application Support/Claude");
const codexDir = join(home, ".codex");
const cursorDir = join(home, ".cursor");
await Promise.all([
mkdir(claudeDir, { recursive: true }),
mkdir(codexDir, { recursive: true }),
mkdir(cursorDir, { recursive: true }),
]);
const claudeConfig = join(claudeDir, "claude_desktop_config.json");
const codexConfig = join(codexDir, "config.toml");
const codexTarget = join(home, "managed-codex.toml");
const cursorConfig = join(cursorDir, "mcp.json");
const cursorTarget = join(home, "managed-cursor.json");
await writeFile(claudeConfig, '{"existing":true}\n');
await writeFile(cursorConfig, '{"existing":true}\n');
await writeFile(codexTarget, 'model = "gpt-5"\n');
await writeFile(cursorTarget, '{"existing":true}\n');
await symlink(codexTarget, codexConfig);
await symlink(cursorTarget, cursorConfig);
const environment = {
...process.env,
HOME: home,
@ -346,6 +356,7 @@ try {
const planned = json(plannedExecution.stdout);
if (supportedSetupHost) {
const plannedClaudeConfig = await readFile(claudeConfig, "utf8");
const plannedCodexConfig = await readFile(codexTarget, "utf8");
const plannedCursorConfig = await readFile(cursorConfig, "utf8");
const plannedSkill = await readFile(skillPath, "utf8");
if (
@ -355,11 +366,12 @@ try {
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
plannedClaudeConfig !== '{"existing":true}\n' ||
plannedCodexConfig !== 'model = "gpt-5"\n' ||
plannedCursorConfig !== '{"existing":true}\n' ||
plannedSkill !== "stale managed skill\n"
)
throw new Error(
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`,
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, codexConfig: plannedCodexConfig, cursorConfig: plannedCursorConfig, skill: plannedSkill })}`,
);
}
const firstExecution = await runWithStatus(
@ -399,6 +411,18 @@ try {
)
throw new Error("packaged client backup failed");
}
const codex = await readFile(codexTarget, "utf8");
if (
!(await lstat(codexConfig)).isSymbolicLink() ||
!(await lstat(cursorConfig)).isSymbolicLink() ||
!codex.includes("[mcp_servers.rea]") ||
!codex.includes(`command = "${cli}"`) ||
(await readFile(`${codexConfig}.rea.backup`, "utf8")) !==
'model = "gpt-5"\n' ||
(await readFile(`${cursorConfig}.rea.backup`, "utf8")) !==
'{"existing":true}\n'
)
throw new Error("packaged setup did not preserve config symlinks");
const skill = await readFile(skillPath, "utf8");
const canonicalSkill = await readFile(
join(root, "skills/rea-analysis/SKILL.md"),
@ -438,6 +462,61 @@ try {
recoveredExecution.status !== (status === "ready" ? 0 : 1)
)
throw new Error("packaged setup did not recover");
const geminiDir = join(home, ".gemini");
const geminiConfig = join(geminiDir, "settings.json");
await mkdir(geminiDir, { recursive: true });
await symlink(join(home, "missing-gemini.json"), geminiConfig);
const danglingExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
);
const dangling = json(danglingExecution.stdout);
if (
dangling.status !== "needs_human" ||
danglingExecution.status !== 1 ||
dangling.clients?.gemini_cli?.status !== "failed" ||
dangling.clients?.gemini_cli?.reason !== "path" ||
!(await lstat(geminiConfig)).isSymbolicLink() ||
(await pathExists(`${geminiConfig}.rea.backup`))
)
throw new Error(
`packaged setup did not reject a dangling config symlink before mutation: ${JSON.stringify(dangling)}`,
);
await rm(geminiDir, { recursive: true });
const uninstallExecution = await runWithStatus(
cli,
["uninstall", "--json"],
environment,
);
const uninstall = json(uninstallExecution.stdout);
const cursorAfterUninstall = json(await readFile(cursorTarget, "utf8"));
const codexAfterUninstall = await readFile(codexTarget, "utf8");
if (
uninstall.status !== "complete" ||
uninstallExecution.status !== 0 ||
uninstall.items?.find(({ name }) => name === "cursor")?.status !==
"removed" ||
uninstall.items?.find(({ name }) => name === "codex")?.status !==
"removed" ||
!(await lstat(cursorConfig)).isSymbolicLink() ||
!(await lstat(codexConfig)).isSymbolicLink() ||
cursorAfterUninstall.existing !== true ||
cursorAfterUninstall.mcpServers?.rea !== undefined ||
!codexAfterUninstall.includes('model = "gpt-5"') ||
codexAfterUninstall.includes("mcp_servers.rea") ||
!(await readFile(`${cursorConfig}.rea.backup`, "utf8")).includes(
'"rea"',
) ||
!(await readFile(`${codexConfig}.rea.backup`, "utf8")).includes(
"[mcp_servers.rea]",
)
)
throw new Error(
`packaged uninstall did not preserve config symlinks: ${JSON.stringify(uninstall)}`,
);
} else if (
first.status !== "needs_human" ||
second.status !== "needs_human"
@ -530,7 +609,7 @@ try {
}
process.stdout.write(
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true })}\n`,
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });
@ -557,3 +636,13 @@ async function runWithStatus(command, args, env) {
function json(text) {
return JSON.parse(text);
}
async function pathExists(path) {
try {
await lstat(path);
return true;
} catch (cause) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return false;
throw cause;
}
}