diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 4603ea416..e93a7ff5e 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -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; + } +}