test(package): cover config symlink lifecycle
This commit is contained in:
parent
df87544a5d
commit
d1ef7a5439
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue