From 81985da1dfd1b3bbd2faae98bf568c96bdbac0a8 Mon Sep 17 00:00:00 2001 From: Chris Dukes Date: Fri, 13 Feb 2026 22:34:42 -0600 Subject: [PATCH 1/6] Recreate launch doctor changes --- .codex-synaptic/memory.db | Bin 638976 -> 651264 bytes AGENTS.md | 7 + README.md | 8 +- docs/guides/codex-macos-workflows.md | 47 +++- docs/guides/quick-start.md | 28 +- src/cli/doctor.ts | 260 ++++++++++++++++++ src/cli/index.ts | 254 +++++++++--------- src/cli/launch.ts | 385 +++++++++++++++++++++++++++ src/env/service-manager.ts | 82 ++++++ tests/cli/doctor.test.ts | 134 ++++++++++ tests/cli/launch.test.ts | 138 ++++++++++ tests/e2e/cli-smoke.test.ts | 12 + tests/env/service-manager.test.ts | 9 + 13 files changed, 1221 insertions(+), 143 deletions(-) create mode 100644 src/cli/doctor.ts create mode 100644 src/cli/launch.ts create mode 100644 tests/cli/doctor.test.ts create mode 100644 tests/cli/launch.test.ts diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index 83089459849034d0efd6c2320d61bf8962d178e8..8c8284600de9fcfbe3234f32eeace056ba85fc91 100644 GIT binary patch delta 2771 zcmbuBdu&uy9>?#@+|JBB%G&$Upt6c@_9=RRk$VlkbrOIR3;F)k3dCAzX~w-&ZR zLXdW_8ClktwudmrAcA2HL1PNTR&>=>A`KB@KwV|)Bj6Gii4icQF|2X-oHKpVZnA%D zGWq7-^PBt0_nhB3zu)QXoY#4NUiXZWJ2g#vzNhN0(4B{y4lIh-ksD$_d!8e6RhZo9 z$yOKsX#R#o#n_%MT)4S78X{rw5~4%$e|zarfz)4)+@&wl=j+w_EWJ`Mr59)){R7=h zPtj-TBU(vB3x}~%q!cOzN)4s)g4^&81OoV+d=Jgop2=lGxlA~hiR3bRE>n`rM01%~ zE)(z17<$v(g!Z9E37LWCBM;sURv2sI>tl~ab0xj{-pEVgW1#`BUV8pjs-uhW&(KwiyawW{343iPz4oY{R2ovsn zzF}(_Z+rX~QQNVY$M~`nx8h8RGyfaTlsF)9%3F{W-molHeSB*rn zzw3(Sn?b1N)xLfsW5fV`Bt(rr6=L;Q|L?uY-G%{O)AU39-wjMYv~xGTKt43^htuui zA#$33o?a{;b{1}LyvNXKpVq*CyCU5|Pidv?54SeAwl8dMXpOwPDlrdlQ8}s58-S03ciTHz~}G>_%t5CZ{yeTQTz%%h+n{a@NV3V zcj7L*4R6L9aSQ%6Zo;eZeRw&(2j7ipnK6W)QIX)3OT3_ z)uK6QCYpj02p}E)0KbFZ!mr^acpeVIkKp_8Bs>n^fc>x!9)btpUib{`g-^j8?1Woj zJ8XsP;99r_-VZZyDQtj?VE`Fi0Bc|soDR!ZLjvP4488|L;2O9BE`l$>Iq(5E4F$d_KzFCOkb8SPLdbehtsN-LF4QCgw2Txpro+mx0nO(>;_$d5zA+f?nv(uo6P z;y`R{fNx(>y`;Fi#|DerJ*x0T>Hp{e;*`Bs_Lcl+=(5s(D!s%*>B`UrUY4#d8&o5I zS9(tAC;aE>S-d5^Ds-H`Bc3j%=T7Takv}UvstVh5II^Xpe? zM+&`@vc=D>^Brq^c@kY?I3gPJC5J`shaNG;?<0$Y;ms$ LCMH8%+GONEex9!5 delta 188 zcmV;t07L(P;3t59Cy*NjD*ylhoRJ_u0V}a!q%R094*CE9005km3@}=efhDtxFli2x zuwMqV`aT8$4hjGO0saIv01x~R_Yd5&4nXJw4iDiEun&n3cn`}Dnh$4@fiahs+6lR{ z?nDR+3JL@R009C60{{tvP=-(ehEM{APy>cg1cp!rhEN8EPzQ!k2)0lO1Yj5n28I9+ q4h|a)DGW9XLJCg_TnK6hcLs*BfocW>fo+5bmthJ4CbzJ*2~Y{*g*ER0 diff --git a/AGENTS.md b/AGENTS.md index ac5cfd7..02ad9b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,13 @@ The Codex-Synaptic system enhances OpenAI's Codex with advanced multi-agent capa - **Autoscaler behaviour:** With the background daemon disabled, idle worker retirement requests cannot execute. Expect scale-down warnings in logs and manually right-size replicas after experiments. See `docs/runbooks/autoscaler-daemon-coordination.md` for operational guidance. - **Repository hygiene:** Active development is running from the local `codex-synaptic-clone` directory, but upstream pushes must target `github.com/clduab11/codex-synaptic`. Align the folder/remote names before release packaging so automation recipes resolve assets correctly. See `docs/runbooks/workspace-rename-guide.md` for the step-by-step procedure. +## Startup Gate (Codex For macOS) + +- Run `codex-synaptic launch --json` before repository work whenever the user asks to launch or verify readiness first. +- Treat launch as a hard gate: if `ok` is `false` (or the command exits non-zero), stop and only return remediation commands. +- Proceed with repository changes only when launch returns `ok=true` and `nextAction="continue"`. +- Default launch gate profiles are `mcp-filesystem`, `mcp-playwright`, and `mcp-desktop-commander`. + ## Core Agent Types ### 1. Worker Agents diff --git a/README.md b/README.md index a5bfbda..d929438 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,10 @@ npm install npm run build # readiness +node dist/cli/index.js launch --json +node dist/cli/index.js launch --strict --json node dist/cli/index.js doctor -node dist/cli/index.js doctor --strict +node dist/cli/index.js doctor --strict --json # daemon lifecycle node dist/cli/index.js background start @@ -76,6 +78,7 @@ node dist/cli/index.js tui --local --interval 1000 # MCP profiles and registration node dist/cli/index.js env plan mcp-filesystem mcp-playwright mcp-desktop-commander +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander node dist/cli/index.js env status mcp-filesystem mcp-playwright mcp-desktop-commander node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desktop-commander --replace @@ -87,6 +90,9 @@ node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desk # Local mode codex -C /absolute/path/to/codex-synaptic +# first-launch gate in this repo +codex-synaptic launch --json + # Worktree mode git worktree add ../codex-synaptic-worktree -b codex/macos-ops codex -C ../codex-synaptic-worktree diff --git a/docs/guides/codex-macos-workflows.md b/docs/guides/codex-macos-workflows.md index 527a2d4..f8f9b56 100644 --- a/docs/guides/codex-macos-workflows.md +++ b/docs/guides/codex-macos-workflows.md @@ -1,6 +1,6 @@ # Codex macOS Workflows (Local, Worktree, Cloud + MCP) -Last reviewed: 2026-02-13 +Last reviewed: 2026-02-14 Audience: contributors using Codex app/CLI on macOS (Apple Silicon) with Codex-Synaptic. ## Source Of Truth @@ -17,7 +17,7 @@ This guide is aligned with: - `https://developers.openai.com/codex/cli/features/` - `https://developers.openai.com/codex/security/` -## Bootstrap And Doctor (Run First) +## Bootstrap And Launch Gate (Run First) ```bash cd /absolute/path/to/codex-synaptic @@ -29,11 +29,40 @@ codex --help codex mcp --help codex mcp add --help -# one-shot readiness checks (auth + mcp + repo cli) -node dist/cli/index.js doctor +# one-command bootstrap + strict readiness gate +node dist/cli/index.js launch --json -# enforce failure in CI/automation -node dist/cli/index.js doctor --strict --json +# explicit strict form for CI/automation +node dist/cli/index.js launch --strict --json +``` + +Launch defaults: + +- Detached runtime authority (`background start`) that remains running after success. +- Required MCP gate set: `mcp-filesystem`, `mcp-playwright`, `mcp-desktop-commander`. +- Hard-stop behavior in strict mode: first failing gate exits non-zero with remediation commands. + +Typical first Codex app prompt in this repo: + +```text +Launch codex-synaptic and determine health/status prior to beginning repository work. +``` + +### Launch Failure Remediation Examples + +```bash +# Codex auth missing +codex login + +# Docker registry auth for MCP images +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander + +# MCP runtime or registration drift +node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander +node dist/cli/index.js env codex-register mcp-filesystem mcp-playwright mcp-desktop-commander --replace + +# Re-run hard gate +node dist/cli/index.js launch --strict --json ``` ## Runtime Model (Deterministic) @@ -130,6 +159,9 @@ codex cloud apply # inspect profiles and codex registration targets node dist/cli/index.js env plan mcp-filesystem mcp-playwright mcp-desktop-commander +# authenticate required Docker registries (for private GHCR images) +node dist/cli/index.js env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander + # safest default: filesystem read-only node dist/cli/index.js env up mcp-filesystem mcp-playwright mcp-desktop-commander @@ -147,6 +179,7 @@ codex mcp list --json Expected indicators: - `env status` returns `running: yes` and `healthy: yes` for active profiles. +- `launch --json` returns `ok: true` and `nextAction: "continue"`. - `doctor` reports MCP profile checks passing and registration present. ## Sandbox And Approval Recommendations @@ -169,7 +202,7 @@ codex --sandbox read-only --ask-for-approval on-request ```bash # 1) refresh build + readiness npm run build -node dist/cli/index.js doctor --strict +node dist/cli/index.js launch --strict --json # 2) run focused work codex exec "Implement one bounded fix with tests" diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index 0e09554..75cee8a 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -1,6 +1,6 @@ # Quick Start (Codex-Synaptic + Codex macOS) -Last reviewed: 2026-02-10 +Last reviewed: 2026-02-14 ## 1. Install and build @@ -9,27 +9,33 @@ npm install npm run build ``` -## 2. Verify CLI health +## 2. Run launch gate ```bash -npm run cli -- system status +npm run cli -- launch --strict --json ``` -Expected output in a cold shell: +Expected success indicators: ```text -System not started. Run `codex-synaptic system start` first. +ok: true +nextAction: continue ``` -Expected output after startup: +If launch fails, stop repository work and run the remediation commands returned in the report. +For Docker registry-denied errors, run: ```bash -npm run cli -- system start +npm run cli -- env docker-login mcp-filesystem mcp-playwright mcp-desktop-commander ``` -This command prints a telemetry snapshot and then exits cleanly in one-shot mode. +## 3. Optional direct runtime inspection + +```bash +npm run cli -- system status +``` -## 3. Run a minimal local workflow +## 4. Run a minimal local workflow ```bash npm run cli -- reasoning plan "Stabilize codex-synaptic release readiness" --require-consensus --json @@ -37,13 +43,13 @@ npm run cli -- openai usage --json npm run cli -- hive-mind spawn "Verify macOS readiness smoke flow" --codex --dry-run ``` -## 4. Use Codex passthrough +## 5. Use Codex passthrough ```bash codex-synaptic --codex --dry-run "Inspect current readiness blockers and propose bounded fixes" ``` -## 5. Run verification gates +## 6. Run verification gates ```bash npm run lint diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..27ad23b --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,260 @@ +import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; + +export const DEFAULT_MCP_PROFILES = [ + 'mcp-filesystem', + 'mcp-playwright', + 'mcp-desktop-commander' +] as const; + +export interface DoctorCheck { + id: string; + ok: boolean; + details: string; + remediation?: string; + metadata?: Record; +} + +export interface DoctorSummary { + passed: number; + failed: number; + total: number; +} + +export interface DoctorReport { + ok: boolean; + summary: DoctorSummary; + checks: DoctorCheck[]; +} + +export interface DoctorOptions { + cwd?: string; + mcpProfiles?: string[]; + skipCodexAuth?: boolean; +} + +export interface DoctorDependencies { + fileExists?: (path: string) => boolean; + spawnCommand?: ( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } + ) => Pick, 'status' | 'stdout' | 'stderr'>; + getServiceStatus?: (name: string) => Promise; + getCodexRegistration?: (name: string) => { codexName: string; url: string } | null; +} + +function parseCodexMcpNames(payload: unknown): string[] { + if (Array.isArray(payload)) { + return payload + .map((entry) => { + if (!entry || typeof entry !== 'object') { + return undefined; + } + return String((entry as { name?: string }).name ?? ''); + }) + .filter(Boolean) as string[]; + } + + if (payload && typeof payload === 'object') { + const candidateArrays = [ + (payload as { servers?: unknown }).servers, + (payload as { items?: unknown }).items, + (payload as { mcpServers?: unknown }).mcpServers + ]; + + for (const candidate of candidateArrays) { + if (!Array.isArray(candidate)) { + continue; + } + + return candidate + .map((entry) => { + if (!entry || typeof entry !== 'object') { + return undefined; + } + return String((entry as { name?: string }).name ?? ''); + }) + .filter(Boolean) as string[]; + } + } + + throw new Error('Unsupported JSON format returned by `codex mcp list --json`.'); +} + +export function parseProfileList(input: string | string[] | undefined, fallback = [...DEFAULT_MCP_PROFILES]): string[] { + if (Array.isArray(input)) { + const normalized = input + .map((item) => item.trim()) + .filter(Boolean); + return normalized.length ? normalized : [...fallback]; + } + + if (typeof input === 'string') { + const normalized = input + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return normalized.length ? normalized : [...fallback]; + } + + return [...fallback]; +} + +export function collectDoctorRemediations(report: DoctorReport): string[] { + const unique = new Set(); + + for (const check of report.checks) { + if (check.ok || !check.remediation) { + continue; + } + + const commands = check.remediation + .split('&&') + .map((item) => item.trim()) + .filter(Boolean); + + for (const command of commands) { + unique.add(command); + } + } + + return Array.from(unique); +} + +export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDependencies = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const profileNames = parseProfileList(options.mcpProfiles); + const fileExists = deps.fileExists ?? existsSync; + const spawnCommand = deps.spawnCommand + ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); + const getCodexRegistration = deps.getCodexRegistration + ?? ((name: string) => serviceManager.codexRegistration(name)); + + const checks: DoctorCheck[] = []; + + const distCliPath = join(cwd, 'dist', 'cli', 'index.js'); + const distExists = fileExists(distCliPath); + checks.push({ + id: 'repo.cli_build_artifact', + ok: distExists, + details: distExists ? `Found ${distCliPath}` : `Missing ${distCliPath}`, + remediation: distExists ? undefined : 'Run `npm run build`.' + }); + + if (distExists) { + const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + cwd, + encoding: 'utf8' + }); + + checks.push({ + id: 'repo.cli_exec', + ok: cliHelp.status === 0, + details: cliHelp.status === 0 + ? 'CLI help command succeeded.' + : (cliHelp.stderr?.trim() || 'CLI help command failed.'), + remediation: cliHelp.status === 0 + ? undefined + : 'Run `npm run build` and re-run `node dist/cli/index.js --help`.' + }); + } + + if (!options.skipCodexAuth) { + const loginStatus = spawnCommand('codex', ['login', 'status'], { + cwd, + encoding: 'utf8' + }); + + const stdout = loginStatus.stdout?.trim() || ''; + const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); + + checks.push({ + id: 'codex.auth', + ok, + details: stdout || loginStatus.stderr?.trim() || 'No output', + remediation: ok ? undefined : 'Run `codex login` then re-run `codex login status`.' + }); + } + + const codexMcpList = spawnCommand('codex', ['mcp', 'list', '--json'], { + cwd, + encoding: 'utf8' + }); + + let codexMcpNames = new Set(); + if (codexMcpList.status === 0) { + try { + const parsed = JSON.parse(codexMcpList.stdout || '[]') as unknown; + const names = parseCodexMcpNames(parsed); + codexMcpNames = new Set(names); + checks.push({ + id: 'codex.mcp_list', + ok: true, + details: `Loaded ${codexMcpNames.size} Codex MCP registration(s).` + }); + } catch (error) { + checks.push({ + id: 'codex.mcp_list', + ok: false, + details: `Failed to parse codex mcp list output: ${(error as Error).message}`, + remediation: 'Run `codex mcp list --json` and inspect output.' + }); + } + } else { + checks.push({ + id: 'codex.mcp_list', + ok: false, + details: codexMcpList.stderr?.trim() || 'codex mcp list failed', + remediation: 'Verify Codex CLI install and MCP support (`codex mcp --help`).' + }); + } + + for (const profileName of profileNames) { + const status = await getServiceStatus(profileName); + const registration = getCodexRegistration(profileName); + const registered = registration ? codexMcpNames.has(registration.codexName) : true; + const healthy = status.healthy !== false; + const ok = status.running && healthy && registered; + + let details = `running=${status.running} healthy=${status.healthy === null ? 'n/a' : status.healthy} registered=${registered}`; + if (status.diagnostics.length) { + details += ` diagnostics=${status.diagnostics.join(' | ')}`; + } + + const remediationParts: string[] = []; + if (!status.running || !healthy) { + remediationParts.push(`codex-synaptic env up ${profileName}`); + } + if (registration && !registered) { + remediationParts.push(`codex-synaptic env codex-register ${profileName}`); + } + + checks.push({ + id: `mcp.${profileName}`, + ok, + details, + remediation: remediationParts.length ? remediationParts.join(' && ') : undefined, + metadata: { + codexName: registration?.codexName, + url: registration?.url + } + }); + } + + const passed = checks.filter((check) => check.ok).length; + const failed = checks.length - passed; + + return { + ok: failed === 0, + summary: { + passed, + failed, + total: checks.length + }, + checks + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index ca61b80..5bd2555 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -74,6 +74,12 @@ import { buildPolicyInput, type QuotaOptions } from './tenant-quota-helpers.js'; +import { + DEFAULT_MCP_PROFILES, + parseProfileList, + runDoctor +} from './doctor.js'; +import { collectLaunchRemediations, runLaunch } from './launch.js'; function loadEnvFile(filePath: string): boolean { if (!existsSync(filePath)) { @@ -4435,6 +4441,34 @@ ${name}`)); }); }); +envCmd + .command('docker-login') + .description('Authenticate Docker registries required by one or more service profiles') + .argument('[names...]', 'Service profile names (defaults to launch gate profiles)') + .option('--dry-run', 'Print docker login commands without executing them') + .action(handleCommand('env.docker-login', async (names: string[] = [], options) => { + const targets = names.length ? names : [...DEFAULT_MCP_PROFILES]; + const registries = serviceManager.registriesForProfiles(targets); + + if (!registries.length) { + console.log(chalk.gray(`No registry authentication required for profiles: ${targets.join(', ')}`)); + return; + } + + if (options.dryRun) { + console.log(chalk.blue('Docker registry login commands (dry-run):')); + registries.forEach((registry) => { + console.log(chalk.gray(` docker login ${registry}`)); + }); + return; + } + + for (const registry of registries) { + serviceManager.dockerLogin(registry); + console.log(chalk.green(`✅ Docker auth completed for ${registry}`)); + } + })); + envCmd .command('codex-register') .description('Register MCP HTTP profiles in Codex CLI MCP config') @@ -4475,6 +4509,91 @@ envCmd } })); +const launchCmd = decorateCommandHelp( + program + .command('launch') + .description('Start detached runtime and hard-gate readiness before repository work'), + { + title: 'Launch Gate', + subtitle: 'Boot daemon + MCP dependencies and fail-fast if the repo is not work-ready.', + context: [ + 'Launch is the single-command bootstrap for Codex for macOS first prompts.', + 'In strict mode, launch stops on the first failing gate and exits non-zero.' + ], + skills: [ + 'Guarantee daemon, MCP profile, and doctor readiness before edits start.', + 'Emit machine-readable launch reports for automation and handoffs.' + ], + actions: [ + { command: 'codex-synaptic launch --json', description: 'Run the full bootstrap gate and emit structured output.' }, + { command: 'codex-synaptic launch --no-strict --json', description: 'Collect gate results without immediate fail-fast exit.' } + ], + docs: [ + { label: 'docs/guides/codex-macos-workflows.md', description: 'Single-command first-launch flow for Codex for macOS.' } + ] + } +); + +launchCmd + .option('--json', 'Output launch report as JSON') + .option('--strict', 'Exit with an error when any launch gate fails', true) + .option('--no-strict', 'Report failing gates without exiting non-zero') + .option('--skip-codex-auth', 'Skip codex login status check') + .option( + '--mcp-profiles ', + 'Comma-separated MCP service profiles to verify', + DEFAULT_MCP_PROFILES.join(',') + ) + .action(handleCommand('launch', async (options) => { + const strict = options.strict !== false; + const profileNames = parseProfileList(options.mcpProfiles, [...DEFAULT_MCP_PROFILES]); + const report = await runLaunch({ + cwd: process.cwd(), + strict, + skipCodexAuth: Boolean(options.skipCodexAuth), + mcpProfiles: profileNames + }); + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(chalk.blue('🚀 Codex-Synaptic Launch')); + console.log(chalk.gray(` Status: ${report.ok ? 'ready' : 'blocked'}`)); + console.log(chalk.gray(` Next action: ${report.nextAction}`)); + + report.steps.forEach((step) => { + const marker = step.ok ? chalk.green('✓') : chalk.red('✗'); + console.log(`${marker} ${step.id}: ${step.details}`); + if (!step.ok && step.remediation) { + console.log(chalk.yellow(` remediation: ${step.remediation}`)); + } + }); + + if (report.doctor.summary.total > 0) { + console.log(chalk.gray(` Doctor summary: passed=${report.doctor.summary.passed} failed=${report.doctor.summary.failed}`)); + } else { + console.log(chalk.gray(' Doctor summary: skipped (launch exited before strict doctor run).')); + } + + if (report.ok) { + console.log(chalk.green('✅ Launch gate passed. Safe to begin repository work.')); + } else { + console.log(chalk.red('🛑 Launch gate failed. Stop repository work until remediations pass.')); + const remediation = collectLaunchRemediations(report); + if (remediation.length) { + console.log(chalk.yellow(' Suggested commands:')); + remediation.forEach((command) => { + console.log(chalk.yellow(` - ${command}`)); + }); + } + } + } + + if (strict && !report.ok) { + throw new Error('Launch failed one or more readiness gates.'); + } + })); + const doctorCmd = decorateCommandHelp( program .command('doctor') @@ -4510,137 +4629,24 @@ doctorCmd .option( '--mcp-profiles ', 'Comma-separated MCP service profiles to verify', - 'mcp-filesystem,mcp-playwright,mcp-desktop-commander' + DEFAULT_MCP_PROFILES.join(',') ) .action(handleCommand('doctor', async (options) => { - const profileNames = String(options.mcpProfiles) - .split(',') - .map((item) => item.trim()) - .filter(Boolean); - - const checks: Array<{ - id: string; - ok: boolean; - details: string; - remediation?: string; - metadata?: Record; - }> = []; - - const distCliPath = join(process.cwd(), 'dist', 'cli', 'index.js'); - const distExists = existsSync(distCliPath); - checks.push({ - id: 'repo.cli_build_artifact', - ok: distExists, - details: distExists ? `Found ${distCliPath}` : `Missing ${distCliPath}`, - remediation: distExists ? undefined : 'Run `npm run build`.' - }); - - if (distExists) { - const cliHelp = spawnSync('node', [distCliPath, '--help'], { - cwd: process.cwd(), - encoding: 'utf8' - }); - checks.push({ - id: 'repo.cli_exec', - ok: cliHelp.status === 0, - details: cliHelp.status === 0 ? 'CLI help command succeeded.' : (cliHelp.stderr?.trim() || 'CLI help command failed.'), - remediation: cliHelp.status === 0 ? undefined : 'Run `npm run build` and re-run `node dist/cli/index.js --help`.' - }); - } - - if (!options.skipCodexAuth) { - const loginStatus = spawnSync('codex', ['login', 'status'], { - cwd: process.cwd(), - encoding: 'utf8' - }); - const stdout = loginStatus.stdout?.trim() || ''; - const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); - checks.push({ - id: 'codex.auth', - ok, - details: stdout || loginStatus.stderr?.trim() || 'No output', - remediation: ok ? undefined : 'Run `codex login` then re-run `codex login status`.' - }); - } - - let codexMcpNames = new Set(); - const codexMcpList = spawnSync('codex', ['mcp', 'list', '--json'], { + const profileNames = parseProfileList(options.mcpProfiles, [...DEFAULT_MCP_PROFILES]); + const report = await runDoctor({ cwd: process.cwd(), - encoding: 'utf8' + mcpProfiles: profileNames, + skipCodexAuth: Boolean(options.skipCodexAuth) }); - if (codexMcpList.status === 0) { - try { - const parsed = JSON.parse(codexMcpList.stdout || '[]') as Array<{ name?: string }>; - codexMcpNames = new Set(parsed.map((entry) => String(entry.name)).filter(Boolean)); - checks.push({ - id: 'codex.mcp_list', - ok: true, - details: `Loaded ${codexMcpNames.size} Codex MCP registration(s).` - }); - } catch (error) { - checks.push({ - id: 'codex.mcp_list', - ok: false, - details: `Failed to parse codex mcp list output: ${(error as Error).message}`, - remediation: 'Run `codex mcp list --json` and inspect output.' - }); - } - } else { - checks.push({ - id: 'codex.mcp_list', - ok: false, - details: codexMcpList.stderr?.trim() || 'codex mcp list failed', - remediation: 'Verify Codex CLI install and MCP support (`codex mcp --help`).' - }); - } - - for (const profileName of profileNames) { - const status = await serviceManager.status(profileName); - const registration = serviceManager.codexRegistration(profileName); - const registered = registration ? codexMcpNames.has(registration.codexName) : true; - const healthy = status.healthy !== false; - const ok = status.running && healthy && registered; - - let details = `running=${status.running} healthy=${status.healthy === null ? 'n/a' : status.healthy} registered=${registered}`; - if (status.diagnostics.length) { - details += ` diagnostics=${status.diagnostics.join(' | ')}`; - } - - const remediationParts: string[] = []; - if (!status.running || !healthy) { - remediationParts.push(`codex-synaptic env up ${profileName}`); - } - if (registration && !registered) { - remediationParts.push(`codex-synaptic env codex-register ${profileName}`); - } - - checks.push({ - id: `mcp.${profileName}`, - ok, - details, - remediation: remediationParts.length ? remediationParts.join(' && ') : undefined, - metadata: { - codexName: registration?.codexName, - url: registration?.url - } - }); - } - - const passed = checks.filter((check) => check.ok).length; - const failed = checks.length - passed; if (options.json) { - console.log(JSON.stringify({ - ok: failed === 0, - summary: { passed, failed, total: checks.length }, - checks - }, null, 2)); + console.log(JSON.stringify(report, null, 2)); } else { console.log(chalk.blue('🩺 Codex-Synaptic Doctor')); - console.log(chalk.gray(` Passed: ${passed}`)); - console.log(chalk.gray(` Failed: ${failed}`)); + console.log(chalk.gray(` Passed: ${report.summary.passed}`)); + console.log(chalk.gray(` Failed: ${report.summary.failed}`)); - checks.forEach((check) => { + report.checks.forEach((check) => { const marker = check.ok ? chalk.green('✓') : chalk.red('✗'); console.log(`${marker} ${check.id}: ${check.details}`); if (!check.ok && check.remediation) { @@ -4649,8 +4655,8 @@ doctorCmd }); } - if (options.strict && failed > 0) { - throw new Error(`Doctor found ${failed} failing check(s).`); + if (options.strict && !report.ok) { + throw new Error(`Doctor found ${report.summary.failed} failing check(s).`); } })); diff --git a/src/cli/launch.ts b/src/cli/launch.ts new file mode 100644 index 0000000..230fdd1 --- /dev/null +++ b/src/cli/launch.ts @@ -0,0 +1,385 @@ +import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { + getBackgroundStatus, + startBackgroundSystem, + type BackgroundStatus +} from './daemon-manager.js'; +import { serviceManager, type EnsureServiceOptions } from '../env/service-manager.js'; +import { + collectDoctorRemediations, + DEFAULT_MCP_PROFILES, + runDoctor, + type DoctorDependencies, + type DoctorOptions, + type DoctorReport +} from './doctor.js'; + +export interface LaunchStep { + id: string; + ok: boolean; + details: string; + remediation?: string; + metadata?: Record; +} + +export interface LaunchReport { + ok: boolean; + steps: LaunchStep[]; + doctor: DoctorReport; + nextAction: 'continue' | 'stop'; +} + +export interface LaunchOptions { + cwd?: string; + strict?: boolean; + skipCodexAuth?: boolean; + mcpProfiles?: string[]; +} + +export interface LaunchDependencies extends DoctorDependencies { + startBackground?: () => Promise; + getBackgroundStatus?: () => BackgroundStatus; + ensureService?: (name: string, options?: EnsureServiceOptions) => Promise; + runDoctor?: (options: DoctorOptions, deps?: DoctorDependencies) => Promise; +} + +const EMPTY_DOCTOR_REPORT: DoctorReport = { + ok: false, + summary: { + passed: 0, + failed: 0, + total: 0 + }, + checks: [] +}; + +function normalizeSpawn( + deps: LaunchDependencies +): ( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } + ) => Pick, 'status' | 'stdout' | 'stderr'> { + return deps.spawnCommand + ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); +} + +function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { + const ok = steps.every((step) => step.ok) && doctorReport.ok; + return { + ok, + steps, + doctor: doctorReport, + nextAction: ok ? 'continue' : 'stop' + }; +} + +function collectLaunchRemediationsFromStep(step: LaunchStep): string[] { + if (!step.remediation) { + return []; + } + + return step.remediation + .split('&&') + .map((item) => item.trim()) + .filter(Boolean); +} + +function buildMcpBootstrapRemediation(profileNames: string[]): string { + const commands: string[] = []; + + commands.push(`codex-synaptic env docker-login ${profileNames.join(' ')}`); + commands.push(`codex-synaptic env up ${profileNames.join(' ')}`); + commands.push(`codex-synaptic env codex-register ${profileNames.join(' ')} --replace`); + + return commands.join(' && '); +} + +export function collectLaunchRemediations(report: LaunchReport): string[] { + const unique = new Set(); + + for (const step of report.steps) { + if (step.ok) { + continue; + } + const commands = collectLaunchRemediationsFromStep(step); + for (const command of commands) { + unique.add(command); + } + } + + for (const command of collectDoctorRemediations(report.doctor)) { + unique.add(command); + } + + return Array.from(unique); +} + +export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDependencies = {}): Promise { + const cwd = options.cwd ?? process.cwd(); + const strict = options.strict !== false; + const profileNames = options.mcpProfiles?.length + ? [...options.mcpProfiles] + : [...DEFAULT_MCP_PROFILES]; + + const fileExists = deps.fileExists ?? existsSync; + const spawnCommand = normalizeSpawn(deps); + const startBackground = deps.startBackground ?? (() => startBackgroundSystem()); + const readBackgroundStatus = deps.getBackgroundStatus ?? (() => getBackgroundStatus()); + const ensureService = deps.ensureService + ?? ((name: string, ensureOptions?: EnsureServiceOptions) => serviceManager.ensureService(name, ensureOptions)); + const executeDoctor = deps.runDoctor ?? runDoctor; + + const steps: LaunchStep[] = []; + let doctorReport = EMPTY_DOCTOR_REPORT; + + const appendStep = (step: LaunchStep): LaunchReport | null => { + steps.push(step); + if (strict && !step.ok) { + return buildLaunchReport(steps, doctorReport); + } + return null; + }; + + const distCliPath = join(cwd, 'dist', 'cli', 'index.js'); + const distExists = fileExists(distCliPath); + + const preflightStep: LaunchStep = distExists + ? (() => { + const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + cwd, + encoding: 'utf8' + }); + + return { + id: 'repo.preflight', + ok: cliHelp.status === 0, + details: cliHelp.status === 0 + ? `Found ${distCliPath}; CLI executable check passed.` + : `CLI executable check failed: ${cliHelp.stderr?.trim() || 'unknown error'}`, + remediation: cliHelp.status === 0 + ? undefined + : 'Run `npm run build` and then `node dist/cli/index.js --help`.' + }; + })() + : { + id: 'repo.preflight', + ok: false, + details: `Missing ${distCliPath}`, + remediation: 'Run `npm run build`.' + }; + + { + const stop = appendStep(preflightStep); + if (stop) { + return stop; + } + } + + const codexAuthStep: LaunchStep = options.skipCodexAuth + ? { + id: 'codex.auth', + ok: true, + details: 'Skipped codex auth check (--skip-codex-auth).' + } + : (() => { + const loginStatus = spawnCommand('codex', ['login', 'status'], { + cwd, + encoding: 'utf8' + }); + const stdout = loginStatus.stdout?.trim() || ''; + const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); + return { + id: 'codex.auth', + ok, + details: stdout || loginStatus.stderr?.trim() || 'No output', + remediation: ok ? undefined : 'Run `codex login` then re-run `codex login status`.' + }; + })(); + + { + const stop = appendStep(codexAuthStep); + if (stop) { + return stop; + } + } + + let daemonStep: LaunchStep; + const existingDaemon = readBackgroundStatus(); + if (existingDaemon.running) { + daemonStep = { + id: 'runtime.daemon', + ok: true, + details: `Background daemon already running (pid ${existingDaemon.pid ?? 'unknown'}).` + }; + } else { + try { + const started = await startBackground(); + daemonStep = { + id: 'runtime.daemon', + ok: started.running, + details: started.running + ? `Background daemon started (pid ${started.pid ?? 'unknown'}).` + : 'Background daemon did not report running state.', + remediation: started.running + ? undefined + : 'Run `codex-synaptic background start` and inspect logs with `codex-synaptic background logs --tail 100`.' + }; + } catch (error) { + daemonStep = { + id: 'runtime.daemon', + ok: false, + details: `Failed to start background daemon: ${(error as Error).message}`, + remediation: 'Run `codex-synaptic background start` and inspect logs with `codex-synaptic background logs --tail 100`.' + }; + } + } + + { + const stop = appendStep(daemonStep); + if (stop) { + return stop; + } + } + + let mcpUpStep: LaunchStep; + if (!profileNames.length) { + mcpUpStep = { + id: 'mcp.up', + ok: true, + details: 'No MCP profiles requested for launch gating.' + }; + } else { + try { + for (const profileName of profileNames) { + await ensureService(profileName, { waitForHealth: true }); + } + mcpUpStep = { + id: 'mcp.up', + ok: true, + details: `Started ${profileNames.length} MCP profile(s): ${profileNames.join(', ')}` + }; + } catch (error) { + mcpUpStep = { + id: 'mcp.up', + ok: false, + details: `Failed to start required MCP profile(s): ${(error as Error).message}`, + remediation: buildMcpBootstrapRemediation(profileNames) + }; + } + } + + { + const stop = appendStep(mcpUpStep); + if (stop) { + return stop; + } + } + + let codexRegisterStep: LaunchStep; + if (!profileNames.length) { + codexRegisterStep = { + id: 'mcp.codex_register', + ok: true, + details: 'No MCP profiles requested for Codex registration.' + }; + } else { + try { + const registeredNames: string[] = []; + for (const profileName of profileNames) { + const registration = (deps.getCodexRegistration ?? serviceManager.codexRegistration.bind(serviceManager))(profileName); + if (!registration) { + continue; + } + + const remove = spawnCommand('codex', ['mcp', 'remove', registration.codexName], { + cwd, + encoding: 'utf8' + }); + + if (remove.status !== 0 && process.env.CODEX_DEBUG === '1') { + const removeMessage = remove.stderr?.trim() || remove.stdout?.trim() || 'unknown remove failure'; + process.stderr.write( + `[launch] codex mcp remove ${registration.codexName} returned non-zero: ${removeMessage}\n` + ); + } + + const add = spawnCommand('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], { + cwd, + encoding: 'utf8' + }); + + if (add.status !== 0) { + const stderr = add.stderr?.trim() || ''; + if (/already exists/i.test(stderr)) { + registeredNames.push(registration.codexName); + continue; + } + + throw new Error( + `codex mcp add failed for ${registration.codexName}: ${stderr || add.stdout?.trim() || 'unknown error'}` + ); + } + + registeredNames.push(registration.codexName); + } + + codexRegisterStep = { + id: 'mcp.codex_register', + ok: true, + details: registeredNames.length + ? `Ensured Codex MCP registration for ${registeredNames.join(', ')}` + : 'Selected MCP profiles do not expose Codex registration metadata.' + }; + } catch (error) { + codexRegisterStep = { + id: 'mcp.codex_register', + ok: false, + details: `Failed to register MCP profile(s) with Codex: ${(error as Error).message}`, + remediation: `codex-synaptic env codex-register ${profileNames.join(' ')} --replace` + }; + } + } + + { + const stop = appendStep(codexRegisterStep); + if (stop) { + return stop; + } + } + + doctorReport = await executeDoctor( + { + cwd, + mcpProfiles: profileNames, + skipCodexAuth: Boolean(options.skipCodexAuth) + }, + { + fileExists, + spawnCommand, + getServiceStatus: deps.getServiceStatus, + getCodexRegistration: deps.getCodexRegistration + } + ); + + const doctorRemediations = collectDoctorRemediations(doctorReport); + const doctorStep: LaunchStep = { + id: 'doctor.strict', + ok: doctorReport.ok, + details: doctorReport.ok + ? `Doctor passed (${doctorReport.summary.passed}/${doctorReport.summary.total}).` + : `Doctor reported ${doctorReport.summary.failed} failing check(s).`, + remediation: doctorRemediations.length ? doctorRemediations.join(' && ') : undefined + }; + + { + const stop = appendStep(doctorStep); + if (stop) { + return stop; + } + } + + return buildLaunchReport(steps, doctorReport); +} diff --git a/src/env/service-manager.ts b/src/env/service-manager.ts index d9014a3..0e1c348 100644 --- a/src/env/service-manager.ts +++ b/src/env/service-manager.ts @@ -10,6 +10,8 @@ export interface ServiceProfile { composeFile: string; services?: string[]; port?: number; + dockerImages?: string[]; + dockerRegistries?: string[]; requiredEnv?: string[]; codexName?: string; healthcheck?: { @@ -73,6 +75,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.github.yml', services: ['mcp-github'], port: 7010, + dockerImages: ['ghcr.io/context-labs/github-mcp:latest'], requiredEnv: ['GITHUB_TOKEN'], codexName: 'github' }, @@ -81,6 +84,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.context7.yml', services: ['mcp-context7'], port: 7020, + dockerImages: ['ghcr.io/context-labs/context7-mcp:latest'], requiredEnv: ['CONTEXT7_API_KEY'], codexName: 'context7' }, @@ -89,6 +93,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.playwright.yml', services: ['mcp-playwright'], port: 7030, + dockerImages: ['ghcr.io/context-labs/playwright-mcp:latest'], codexName: 'playwright-local' }, 'mcp-filesystem': { @@ -96,6 +101,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.filesystem.yml', services: ['mcp-filesystem'], port: 7040, + dockerImages: ['ghcr.io/context-labs/filesystem-mcp:latest'], codexName: 'filesystem-local' }, 'mcp-desktop-commander': { @@ -103,6 +109,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.desktop-commander.yml', services: ['mcp-desktop-commander'], port: 7070, + dockerImages: ['ghcr.io/wonderwhy-er/desktop-commander:latest'], codexName: 'desktop-commander' }, 'mcp-tavily': { @@ -110,6 +117,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.tavily.yml', services: ['mcp-tavily'], port: 7050, + dockerImages: ['ghcr.io/context-labs/tavily-mcp:latest'], requiredEnv: ['TAVILY_API_KEY'], codexName: 'tavily' }, @@ -118,6 +126,7 @@ const PROFILES: Record = { composeFile: 'docker/mcp/docker-compose.firecrawl.yml', services: ['mcp-firecrawl'], port: 7060, + dockerImages: ['ghcr.io/firecrawl/firecrawl-mcp:latest'], requiredEnv: ['FIRECRAWL_API_KEY'], codexName: 'firecrawl' } @@ -269,6 +278,79 @@ class ServiceManager { }; } + dockerImagesForProfiles(names: string[]): string[] { + const images = new Set(); + + for (const name of names) { + const profile = this.getProfile(name); + for (const image of profile.dockerImages ?? []) { + const normalized = image.trim(); + if (normalized) { + images.add(normalized); + } + } + } + + return Array.from(images); + } + + registriesForProfiles(names: string[]): string[] { + const registries = new Set(); + + for (const name of names) { + const profile = this.getProfile(name); + + for (const registry of profile.dockerRegistries ?? []) { + const normalized = registry.trim(); + if (normalized) { + registries.add(normalized); + } + } + + for (const image of profile.dockerImages ?? []) { + const registry = this.registryForImage(image); + if (registry) { + registries.add(registry); + } + } + } + + return Array.from(registries); + } + + dockerLogin(registry: string): void { + const normalized = registry.trim(); + if (!normalized) { + throw new Error('Docker registry is required for docker login.'); + } + const cmd = `docker login ${normalized}`; + this.logger.info('env', 'Authenticating Docker registry', { registry: normalized }); + execSync(cmd, { stdio: 'inherit' }); + } + + private registryForImage(image: string): string | null { + const normalized = image.trim(); + if (!normalized) { + return null; + } + + const firstSegment = normalized.split('/')[0] ?? ''; + if (!firstSegment) { + return null; + } + + // Registry host is explicit only when the first segment contains host-like syntax. + if ( + firstSegment.includes('.') + || firstSegment.includes(':') + || firstSegment === 'localhost' + ) { + return firstSegment; + } + + return null; + } + private async probeService(profile: ServiceProfile): Promise { if (profile.healthcheck?.url) { return this.probeHttp(profile.healthcheck.url, 2000); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts new file mode 100644 index 0000000..33a8a90 --- /dev/null +++ b/tests/cli/doctor.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { runDoctor, type DoctorDependencies } from '../../src/cli/doctor'; +import type { ServiceStatus } from '../../src/env/service-manager'; + +function serviceStatus(overrides: Partial = {}): ServiceStatus { + return { + name: 'mcp-filesystem', + running: true, + healthy: true, + raw: 'ok', + diagnostics: [], + checkedAt: '2026-02-14T00:00:00.000Z', + ...overrides + }; +} + +describe('runDoctor', () => { + it('fails when the dist CLI artifact is missing', async () => { + const deps: DoctorDependencies = { + fileExists: () => false, + spawnCommand: (command, args) => { + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: '[{"name":"filesystem-local"}]', + stderr: '' + }; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus(), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + expect(report.ok).toBe(false); + expect(report.summary.failed).toBe(1); + expect(report.checks.find((check) => check.id === 'repo.cli_build_artifact')?.ok).toBe(false); + }); + + it('passes all checks when auth, MCP registration, and services are healthy', async () => { + const profileRegistrations: Record = { + 'mcp-filesystem': { codexName: 'filesystem-local', url: 'http://localhost:7040' }, + 'mcp-playwright': { codexName: 'playwright-local', url: 'http://localhost:7030' } + }; + + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'login status') { + return { status: 0, stdout: 'Logged in as test-user', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: JSON.stringify([ + { name: 'filesystem-local' }, + { name: 'playwright-local' } + ]), + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async (name) => serviceStatus({ name }), + getCodexRegistration: (name) => profileRegistrations[name] ?? null + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + mcpProfiles: ['mcp-filesystem', 'mcp-playwright'] + }, + deps + ); + + expect(report.ok).toBe(true); + expect(report.summary.failed).toBe(0); + expect(report.checks.find((check) => check.id === 'repo.cli_exec')?.ok).toBe(true); + expect(report.checks.find((check) => check.id === 'mcp.mcp-filesystem')?.ok).toBe(true); + expect(report.checks.find((check) => check.id === 'mcp.mcp-playwright')?.ok).toBe(true); + }); + + it('returns actionable remediation for failing MCP profile checks', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: '[]', + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus({ running: false, healthy: false }), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + const mcpCheck = report.checks.find((check) => check.id === 'mcp.mcp-filesystem'); + expect(mcpCheck?.ok).toBe(false); + expect(mcpCheck?.remediation).toContain('codex-synaptic env up mcp-filesystem'); + expect(mcpCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); + }); +}); diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts new file mode 100644 index 0000000..ffb7509 --- /dev/null +++ b/tests/cli/launch.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { runLaunch, type LaunchDependencies } from '../../src/cli/launch'; +import type { DoctorReport } from '../../src/cli/doctor'; + +const passingDoctorReport: DoctorReport = { + ok: true, + summary: { passed: 6, failed: 0, total: 6 }, + checks: [] +}; + +describe('runLaunch', () => { + it('returns ready=true when all launch gates pass', async () => { + const ensuredProfiles: string[] = []; + const spawnCalls: string[] = []; + + const deps: LaunchDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + spawnCalls.push(`${command} ${args.join(' ')}`); + + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'login status') { + return { status: 0, stdout: 'Logged in as test-user', stderr: '' }; + } + + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'remove') { + return { status: 0, stdout: '', stderr: '' }; + } + + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'add') { + return { status: 0, stdout: '', stderr: '' }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getBackgroundStatus: () => ({ running: false }), + startBackground: async () => ({ running: true, pid: 43210 }), + ensureService: async (name) => { + ensuredProfiles.push(name); + }, + getCodexRegistration: (name) => { + if (name === 'mcp-filesystem') { + return { codexName: 'filesystem-local', url: 'http://localhost:7040' }; + } + if (name === 'mcp-playwright') { + return { codexName: 'playwright-local', url: 'http://localhost:7030' }; + } + return null; + }, + runDoctor: async () => passingDoctorReport + }; + + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + mcpProfiles: ['mcp-filesystem', 'mcp-playwright'] + }, + deps + ); + + expect(report.ok).toBe(true); + expect(report.nextAction).toBe('continue'); + expect(report.steps.map((step) => step.id)).toEqual([ + 'repo.preflight', + 'codex.auth', + 'runtime.daemon', + 'mcp.up', + 'mcp.codex_register', + 'doctor.strict' + ]); + expect(ensuredProfiles).toEqual(['mcp-filesystem', 'mcp-playwright']); + expect(spawnCalls).toContain('codex mcp add filesystem-local --url http://localhost:7040'); + expect(spawnCalls).toContain('codex mcp add playwright-local --url http://localhost:7030'); + }); + + it('fail-fast stops immediately on the first failing gate in strict mode', async () => { + let doctorCalled = false; + + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + mcpProfiles: ['mcp-filesystem'] + }, + { + fileExists: () => false, + runDoctor: async () => { + doctorCalled = true; + return passingDoctorReport; + } + } + ); + + expect(report.ok).toBe(false); + expect(report.nextAction).toBe('stop'); + expect(report.steps).toHaveLength(1); + expect(report.steps[0].id).toBe('repo.preflight'); + expect(report.doctor.summary.total).toBe(0); + expect(doctorCalled).toBe(false); + }); + + it('returns remediation commands when MCP startup fails', async () => { + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getBackgroundStatus: () => ({ running: true, pid: 999 }), + ensureService: async () => { + throw new Error('docker compose timeout'); + }, + runDoctor: async () => passingDoctorReport + } + ); + + expect(report.ok).toBe(false); + expect(report.nextAction).toBe('stop'); + const mcpStep = report.steps.find((step) => step.id === 'mcp.up'); + expect(mcpStep?.ok).toBe(false); + expect(mcpStep?.remediation).toContain('codex-synaptic env docker-login mcp-filesystem'); + expect(mcpStep?.remediation).toContain('codex-synaptic env up mcp-filesystem'); + expect(mcpStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); + }); +}); diff --git a/tests/e2e/cli-smoke.test.ts b/tests/e2e/cli-smoke.test.ts index 50f14ba..9458069 100644 --- a/tests/e2e/cli-smoke.test.ts +++ b/tests/e2e/cli-smoke.test.ts @@ -106,12 +106,24 @@ describe('codex-synaptic CLI smoke suite', () => { expect(stdout).toContain('repo.cli_build_artifact'); }); + it('exposes launch command gating options', () => { + const { stdout } = runCli(['launch', '--help']); + expect(stdout).toContain('--mcp-profiles'); + expect(stdout).toContain('--no-strict'); + expect(stdout).toContain('--skip-codex-auth'); + }); + it('includes desktop commander profile in env planning', () => { const { stdout } = runCli(['env', 'plan', 'mcp-desktop-commander']); expect(stdout).toContain('mcp-desktop-commander'); expect(stdout).toContain('codex mcp name'); }); + it('exposes docker-login helper under env command surface', () => { + const { stdout } = runCli(['env', '--help']); + expect(stdout).toContain('docker-login'); + }); + it('exposes the tui command surface', () => { const { stdout } = runCli(['tui', '--help']); expect(stdout).toContain('attach-daemon'); diff --git a/tests/env/service-manager.test.ts b/tests/env/service-manager.test.ts index a0bd65d..445aafe 100644 --- a/tests/env/service-manager.test.ts +++ b/tests/env/service-manager.test.ts @@ -31,4 +31,13 @@ describe('serviceManager profiles', () => { url: 'http://localhost:7040' }); }); + + it('derives docker registries for MCP profiles', () => { + const registries = serviceManager.registriesForProfiles([ + 'mcp-filesystem', + 'mcp-playwright', + 'mcp-desktop-commander' + ]); + expect(registries).toContain('ghcr.io'); + }); }); From d97253c3bd64535f82e82ab39dc99aa18bc542f2 Mon Sep 17 00:00:00 2001 From: Chris Dukes Date: Fri, 13 Feb 2026 23:35:15 -0600 Subject: [PATCH 2/6] Recreate launch doctor workflow --- .codex-synaptic/memory.db | Bin 651264 -> 667648 bytes src/cli/index.ts | 14 +++++-- src/cli/launch.ts | 20 ++++++++-- src/core/errors.ts | 13 ++++++ tests/cli/doctor.test.ts | 82 ++++++++++++++++++++++++++++++++++++++ tests/cli/launch.test.ts | 43 ++++++++++++++++++++ 6 files changed, 166 insertions(+), 6 deletions(-) diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index 8c8284600de9fcfbe3234f32eeace056ba85fc91..427c8704290c8257fe2d387b5af30ea0f0eee802 100644 GIT binary patch delta 6389 zcmZu#Ym8l06~6bLd++Q$uXaKw0z;wG*Yv?m&)H``2k3AqwUjb#hXUo@8BLinV3+}o z0fga(gM<*3$q|pnAXFeo(1Zq)qNv!2CdLq369dL3LZhM$DCO}3{ef$*eeRsYJ(KjV zWV+gK*6aJ$+V}o9hwguK!=nSW2TP^WQwyuc$`2l%e0mFEy(i!=N>9bz{*9#f-@X6r zJ=uF=p)v5{BmPA1nc(?Bv={85XbkL=(FoWlqLmMPxb>kAx0c~kfsY5DDtv12sl%rO zKIF)UTj}xz?=s>Y={QC|D9_U$(`V@+`aSv>{U&{wK0xoKU!imKR(g;gpx4oTbe!&> z4SE3;bPL@`*U**pGqjI((Hi-Ld`vzd?~=F4TjVd~Pvj5ecjVXP7vu;zOr9stkSEA@ zNld;@9wJS05BV~=oqU0Op4>$ClL@kmY$s#HkiL)%lOZxl21q|yMkvJf-{bVI+I-C` zRVq_xC(#~2do$Xb(B6pl2DI0sy$#x8ccA>o-?M}3pq1}P@vuL-Yy%g;>w3nc5puHIF7}~99FG71E+6&N*qBUrB&6|f` zukZ(nR-ol*L$nNSfYwL51?>phVYKI?-Hdh<+Kp&8pdCWH9_>1`YtgO&8&)c-@rOaQ ztI(c@_FS|p(Vl~L0PWdFUi>qisIOgKdZ)B(dG9$r8@pB4rJWP>0J*K>?)pQuZ&iQj z9jUxpexvkG96b6PZzfuqOuScFPgUUaP%EL5!#}I6NRB0RcTLGa^LXmj4awfOY4>V1 zD#VDA;q|NUWj>R`J{u1BDxZ&r*3+Dc&Os>RpqNM|j#v5-sVark@#&X0wt##95@snN zUo>xz2I9W?G$dt1YP>?HNFa<3g%0A`c-Y~{(ZJ%UB98SmzZjjJOm&u9Kn#G6c9dOi zR4{EqUrNaDYiU?wlmQ$UML6q${LaGsl3)Iv){ngLH@cx~QxA~7ue77ItiN}2&sg`q zuIbMEXiT2%c)5P8cB1-TwdVCzhRR`S$3kOe6(B|sqSKa$D<(9H8*`KOFi_mER;9bv z2sR28(yW*ndYZG*aB{rAyx!qRWpM`YoTQ&KcX!R`AoP>+DH^O1zO9aCL7_V6fsi)mLJ|;CkTf5oQytncWh6_s zzEl~pZR=XY&1eWvL9zenX--F*l7(k0X+#1d3gX86WSuE(v>#7AGnC@!QEqW~AtY`+ zQ0Joi=1tSXJ+t$~DyaI|&c7m;wZXQK_69I}LSh$xxpEw8XO>X1yT0P;I} zcribP-Sd~o&w3!gAw$#U z<{HKzDve{!LI$D|&pp^`SZEujbaAY?^)wGgYm!5+r((nd%lzhv3TEdq&XN~SR$535 zwMZAWh+7YoX>~5S03%9=+5;U)mOoe7lnxP6P3huX0}?cACK}8k`4E$e8=st!Joy~m zQ`Lbq8EZK*O{pa+Zao0C3ZY!{eA~R{BlQ&y%4eZSj`w)2k|W)eT4J`X2UvSv#@f(e zSdgI8ZD5?EEnAILQ>vICV3M&yhZay+uw}Wff zgPUBsPK^3)@O3+bQV$?)%_9LHwbehMK;ZlFTR%rZM<67D*6j%ZP)dS}azIuhAf*3U zi5RbO!4ymSo~vYN$OEle8>IjZc`y@Qm^D~fnTQ7;u;V*k=bW2Rv}T2_h5v$N(5jsb zsRsl#Io4kuLWvh35?Zw)BRrJSfN&&7-*=jWsUjt`S|T{4#B?;8kyL;2n`|Qq$HZ`nh*Bo{xrpHS(NLPpJr`0f3=I!j zT!i!-6T{*%K@W)JmW(+Z4pJRv=`j$pY!Y_DeFjIe4wq=4^*F^GD=k~myQ)WY@9Ns$ zc`KbK-|l$6{z~ol)z`gu;LWq5yty>C(CD{{xi?Egf}p_Bl5(~tpk*vMeTp)m219C? zLbFp34B#FdKvWY6YGQ%rCo3?BN6&R_wlocp5{9>Lozw&I?9Rml8Uirxq8hKm4XR-q z=2SdDRv6w+FhEQ1v+*1nXo!KjzUIVNzy>pm(~PkpHB2ZA;v3+NHXU7&bxDGR7}s(u zNuH3xZnV(mh`ca`Zl@klkzLs#D?vpds&pj~5RuT=`qRQ2AUjOhKGFb)?3j%%&jUhW z%&t8EV>}RWIsWxF4=-cP68T|Raq|T!KX&G721){8gLh8ZWd*A%z1R>483J4^mJ1m& z9bJ|y7f6X=$PiR;5o!T1qx8~7IOK?7?Hqw5)6;E7Hehx%?Q;fS5#u!x27ZwA4V2g8 zI)L35AWKZ)F_C(#%)K>NPh}2rFz^7Fo2s!;f*#~2439Sxv}+vVpvG;_ss$SqfO1I& z@cL6N8NeiQOM#3QYld5DHo7$HB+R=oev6J&_%KqwRy_GtuZK@BM0C9Lmk2ODVzGiJ%AW?aAywQH)7H4(^z$sQ|Wl{%#6^4Hl=E0(wA* z4F!F7dc-M|w8Da~LRWnti30I-uV;+Tt**ku5WtWn z2cv7T`p6P#bp;=W7Of_Q;xZFYo2y%3g{NWafiCRNx&WoJv9*@86h8a(z%sYJRB=h8 z2L|n$Tpv(e7XIZJEa8csd(+s^?4J=Mi%mrjjM>$>M4{;Li1-Obw3-Y<4dZ{Z;K_K7cJ8=@t zU+mhy*f9bz6A&{4F$)m00x=s9vjZ{5_Ahpv0uz}TI5%%+zrgvKaeLP#&Pvve6C9Yf zZ@$SH$R@+XvVwtMfKP^3lgFIfg)5LVo+F37imjb>3d@R(jU6m3jTtSH(;a!aw6 + ) { + super(code, message, context, false); + this.name = 'CliGateError'; + } +} + export class AgentError extends CodexSynapticError { constructor(code: ErrorCode, message: string, context?: Record, retryable: boolean = true) { super(code, message, context, retryable); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 33a8a90..2ac04d7 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -46,6 +46,47 @@ describe('runDoctor', () => { expect(report.checks.find((check) => check.id === 'repo.cli_build_artifact')?.ok).toBe(false); }); + it('fails codex auth check when codex login status returns non-zero', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'login status') { + return { status: 1, stdout: '', stderr: 'Not logged in' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: '[{"name":"filesystem-local"}]', + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus(), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + const authCheck = report.checks.find((check) => check.id === 'codex.auth'); + expect(authCheck?.ok).toBe(false); + expect(authCheck?.details).toContain('Not logged in'); + expect(authCheck?.remediation).toContain('codex login'); + expect(report.ok).toBe(false); + }); + it('passes all checks when auth, MCP registration, and services are healthy', async () => { const profileRegistrations: Record = { 'mcp-filesystem': { codexName: 'filesystem-local', url: 'http://localhost:7040' }, @@ -131,4 +172,45 @@ describe('runDoctor', () => { expect(mcpCheck?.remediation).toContain('codex-synaptic env up mcp-filesystem'); expect(mcpCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); }); + + it('fails codex MCP parsing checks when codex mcp list returns malformed JSON', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + + if (command === 'codex' && args.join(' ') === 'mcp list --json') { + return { + status: 0, + stdout: 'not-json', + stderr: '' + }; + } + + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getServiceStatus: async () => serviceStatus(), + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + }; + + const report = await runDoctor( + { + cwd: '/tmp/codex-synaptic', + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + deps + ); + + const mcpListCheck = report.checks.find((check) => check.id === 'codex.mcp_list'); + expect(mcpListCheck?.ok).toBe(false); + expect(mcpListCheck?.remediation).toContain('codex mcp list --json'); + + const mcpProfileCheck = report.checks.find((check) => check.id === 'mcp.mcp-filesystem'); + expect(mcpProfileCheck?.ok).toBe(false); + expect(mcpProfileCheck?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem'); + expect(report.ok).toBe(false); + }); }); diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts index ffb7509..6ee818a 100644 --- a/tests/cli/launch.test.ts +++ b/tests/cli/launch.test.ts @@ -135,4 +135,47 @@ describe('runLaunch', () => { expect(mcpStep?.remediation).toContain('codex-synaptic env up mcp-filesystem'); expect(mcpStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); }); + + it('captures MCP bridge error classification when codex registration add fails', async () => { + let doctorCalled = false; + const report = await runLaunch( + { + cwd: '/tmp/codex-synaptic', + strict: true, + skipCodexAuth: true, + mcpProfiles: ['mcp-filesystem'] + }, + { + fileExists: () => true, + spawnCommand: (command, args) => { + if (command === 'node' && args.includes('--help')) { + return { status: 0, stdout: 'ok', stderr: '' }; + } + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'remove') { + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'codex' && args[0] === 'mcp' && args[1] === 'add') { + return { status: 1, stdout: 'denied', stderr: 'permission denied' }; + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); + }, + getBackgroundStatus: () => ({ running: true, pid: 999 }), + ensureService: async () => {}, + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }), + runDoctor: async () => { + doctorCalled = true; + return passingDoctorReport; + } + } + ); + + expect(report.ok).toBe(false); + expect(report.nextAction).toBe('stop'); + expect(doctorCalled).toBe(false); + const registrationStep = report.steps.find((step) => step.id === 'mcp.codex_register'); + expect(registrationStep?.ok).toBe(false); + expect(registrationStep?.details).toContain('codex mcp add failed for filesystem-local'); + expect(registrationStep?.remediation).toContain('codex-synaptic env codex-register mcp-filesystem --replace'); + expect((registrationStep?.metadata as { code?: string } | undefined)?.code).toBe('MCP_ERROR'); + }); }); From 4cf630c225e144a790a5a11ead90b4063500977c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:17:28 +0000 Subject: [PATCH 3/6] Initial plan From 67bca747004097b9ecc85287cb49aaa58beab7c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:21:46 +0000 Subject: [PATCH 4/6] Make normalizeSpawn async to prevent event loop blocking - Update DoctorDependencies.spawnCommand to return Promise - Update LaunchDependencies (extends DoctorDependencies) - Modify normalizeSpawn to return async wrapper function - Await all spawnCommand calls in launch.ts (preflight, auth, mcp registration) - Await all spawnCommand calls in doctor.ts (cli help, login status, mcp list) - Update launch.test.ts with async spawnCommand mocks - Update doctor.test.ts with async spawnCommand mocks - All tests pass (9/9) Co-authored-by: clduab11 <185000089+clduab11@users.noreply.github.com> --- src/cli/doctor.ts | 10 +++++----- src/cli/launch.ts | 16 ++++++++-------- tests/cli/doctor.test.ts | 10 +++++----- tests/cli/launch.test.ts | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 27ad23b..0d818b3 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -41,7 +41,7 @@ export interface DoctorDependencies { command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'>; + ) => Promise, 'status' | 'stdout' | 'stderr'>>; getServiceStatus?: (name: string) => Promise; getCodexRegistration?: (name: string) => { codexName: string; url: string } | null; } @@ -129,7 +129,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const profileNames = parseProfileList(options.mcpProfiles); const fileExists = deps.fileExists ?? existsSync; const spawnCommand = deps.spawnCommand - ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); const getCodexRegistration = deps.getCodexRegistration ?? ((name: string) => serviceManager.codexRegistration(name)); @@ -146,7 +146,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende }); if (distExists) { - const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + const cliHelp = await spawnCommand('node', [distCliPath, '--help'], { cwd, encoding: 'utf8' }); @@ -164,7 +164,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende } if (!options.skipCodexAuth) { - const loginStatus = spawnCommand('codex', ['login', 'status'], { + const loginStatus = await spawnCommand('codex', ['login', 'status'], { cwd, encoding: 'utf8' }); @@ -180,7 +180,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende }); } - const codexMcpList = spawnCommand('codex', ['mcp', 'list', '--json'], { + const codexMcpList = await spawnCommand('codex', ['mcp', 'list', '--json'], { cwd, encoding: 'utf8' }); diff --git a/src/cli/launch.ts b/src/cli/launch.ts index 71e4892..dcb849a 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -62,9 +62,9 @@ function normalizeSpawn( command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'> { + ) => Promise, 'status' | 'stdout' | 'stderr'>> { return deps.spawnCommand - ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); } function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { @@ -148,8 +148,8 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende const distExists = fileExists(distCliPath); const preflightStep: LaunchStep = distExists - ? (() => { - const cliHelp = spawnCommand('node', [distCliPath, '--help'], { + ? await (async () => { + const cliHelp = await spawnCommand('node', [distCliPath, '--help'], { cwd, encoding: 'utf8' }); @@ -185,8 +185,8 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende ok: true, details: 'Skipped codex auth check (--skip-codex-auth).' } - : (() => { - const loginStatus = spawnCommand('codex', ['login', 'status'], { + : await (async () => { + const loginStatus = await spawnCommand('codex', ['login', 'status'], { cwd, encoding: 'utf8' }); @@ -295,7 +295,7 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende continue; } - const remove = spawnCommand('codex', ['mcp', 'remove', registration.codexName], { + const remove = await spawnCommand('codex', ['mcp', 'remove', registration.codexName], { cwd, encoding: 'utf8' }); @@ -307,7 +307,7 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende ); } - const add = spawnCommand('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], { + const add = await spawnCommand('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], { cwd, encoding: 'utf8' }); diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 2ac04d7..fe86ca8 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -18,7 +18,7 @@ describe('runDoctor', () => { it('fails when the dist CLI artifact is missing', async () => { const deps: DoctorDependencies = { fileExists: () => false, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'codex' && args.join(' ') === 'mcp list --json') { return { status: 0, @@ -49,7 +49,7 @@ describe('runDoctor', () => { it('fails codex auth check when codex login status returns non-zero', async () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -95,7 +95,7 @@ describe('runDoctor', () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -139,7 +139,7 @@ describe('runDoctor', () => { it('returns actionable remediation for failing MCP profile checks', async () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -176,7 +176,7 @@ describe('runDoctor', () => { it('fails codex MCP parsing checks when codex mcp list returns malformed JSON', async () => { const deps: DoctorDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts index 6ee818a..cbd2320 100644 --- a/tests/cli/launch.test.ts +++ b/tests/cli/launch.test.ts @@ -15,7 +15,7 @@ describe('runLaunch', () => { const deps: LaunchDependencies = { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { spawnCalls.push(`${command} ${args.join(' ')}`); if (command === 'node' && args.includes('--help')) { @@ -113,7 +113,7 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -147,7 +147,7 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } From be393beb7fb1b670a17f977eaf67087fb965ebbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:23:58 +0000 Subject: [PATCH 5/6] Use truly async spawn instead of wrapped spawnSync Replace wrapped spawnSync with proper async spawn implementation: - Add spawnAsync helper using child_process.spawn with Promise - Update doctor.ts to use spawnAsync by default - Update launch.ts to use spawnAsync by default - Prevents actual event loop blocking (not just wrapping sync in async) - All tests still pass (9/9) Co-authored-by: clduab11 <185000089+clduab11@users.noreply.github.com> --- src/cli/doctor.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++--- src/cli/launch.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 0d818b3..3a48246 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,8 +1,51 @@ -import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { spawn, spawnSync, type SpawnSyncReturns } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; +/** + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + */ +function spawnAsync( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } +): Promise, 'status' | 'stdout' | 'stderr'>> { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(options.encoding); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(options.encoding); + }); + + child.on('close', (code) => { + resolve({ + status: code ?? 0, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + resolve({ + status: 1, + stdout, + stderr: stderr || error.message + }); + }); + }); +} + export const DEFAULT_MCP_PROFILES = [ 'mcp-filesystem', 'mcp-playwright', @@ -128,8 +171,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const cwd = options.cwd ?? process.cwd(); const profileNames = parseProfileList(options.mcpProfiles); const fileExists = deps.fileExists ?? existsSync; - const spawnCommand = deps.spawnCommand - ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + const spawnCommand = deps.spawnCommand ?? spawnAsync; const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); const getCodexRegistration = deps.getCodexRegistration ?? ((name: string) => serviceManager.codexRegistration(name)); diff --git a/src/cli/launch.ts b/src/cli/launch.ts index dcb849a..58a5b4a 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -1,4 +1,4 @@ -import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { spawn, spawnSync, type SpawnSyncReturns } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { @@ -17,6 +17,49 @@ import { } from './doctor.js'; import { BridgeError, ErrorCode } from '../core/errors.js'; +/** + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + */ +function spawnAsync( + command: string, + args: string[], + options: { cwd: string; encoding: BufferEncoding } +): Promise, 'status' | 'stdout' | 'stderr'>> { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(options.encoding); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(options.encoding); + }); + + child.on('close', (code) => { + resolve({ + status: code ?? 0, + stdout, + stderr + }); + }); + + child.on('error', (error) => { + resolve({ + status: 1, + stdout, + stderr: stderr || error.message + }); + }); + }); +} + export interface LaunchStep { id: string; ok: boolean; @@ -63,8 +106,7 @@ function normalizeSpawn( args: string[], options: { cwd: string; encoding: BufferEncoding } ) => Promise, 'status' | 'stdout' | 'stderr'>> { - return deps.spawnCommand - ?? (async (command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + return deps.spawnCommand ?? spawnAsync; } function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { From 7342c0651bbe503e8095c358c1a1cf26c20345c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:24:59 +0000 Subject: [PATCH 6/6] Add clarifying comments to spawnAsync error handling Document that spawnAsync always resolves (never rejects) to match spawnSync API: - Errors communicated via status code and stderr - Matches expected behavior of calling code - Prevents unnecessary try/catch blocks in callers - All tests still pass (9/9) Co-authored-by: clduab11 <185000089+clduab11@users.noreply.github.com> --- src/cli/doctor.ts | 4 +++- src/cli/launch.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 3a48246..4cc9d8c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -4,7 +4,9 @@ import { join } from 'path'; import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; /** - * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code. + * Note: This function always resolves (never rejects) to match spawnSync behavior. + * Errors are communicated via status code and stderr, not via Promise rejection. */ function spawnAsync( command: string, diff --git a/src/cli/launch.ts b/src/cli/launch.ts index 58a5b4a..72935f2 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -18,7 +18,9 @@ import { import { BridgeError, ErrorCode } from '../core/errors.js'; /** - * Promisified spawn wrapper that collects stdout/stderr and resolves with status code + * Promisified spawn wrapper that collects stdout/stderr and resolves with status code. + * Note: This function always resolves (never rejects) to match spawnSync behavior. + * Errors are communicated via status code and stderr, not via Promise rejection. */ function spawnAsync( command: string,