diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index 75cee8a..54fa862 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -17,9 +17,11 @@ npm run cli -- launch --strict --json Expected success indicators: -```text -ok: true -nextAction: continue +```json +{ + "ok": true, + "nextAction": "continue" +} ``` If launch fails, stop repository work and run the remediation commands returned in the report. diff --git a/docs/uat/UAT_READINESS_TRACKER.md b/docs/uat/UAT_READINESS_TRACKER.md index af66264..54d4a98 100644 --- a/docs/uat/UAT_READINESS_TRACKER.md +++ b/docs/uat/UAT_READINESS_TRACKER.md @@ -88,7 +88,7 @@ Primary objectives (priority order): #### What changed -- Added `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` +- Added `docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` - Exact bootstrap and readiness commands (`build`, `env plan`, `env docker-login`, `env up`, `env status`, `env codex-register`, `doctor --strict --json`, `launch --strict --json`) - Explicit pass/fail JSON criteria for `doctor` and `launch` - Deterministic fail/block conditions for UAT status @@ -124,7 +124,7 @@ Primary objectives (priority order): #### What changed -- Added `/Users/chrisdukes/LocalProjects/codex-synaptic/.github/workflows/ci-non-mcp-gates.yml` +- Added `.github/workflows/ci-non-mcp-gates.yml` - Triggers: `push`, `pull_request`, `workflow_dispatch` - Single Ubuntu/Node 20 job with pinned `actions/checkout` and `actions/setup-node` - Runs `npm ci`, `npm run build`, `npm test`, `npm run lint` @@ -173,7 +173,7 @@ Primary objectives (priority order): #### What changed -- Added `/Users/chrisdukes/LocalProjects/codex-synaptic/src/cli/env-bootstrap.ts` +- Added `src/cli/env-bootstrap.ts` - Extracted CLI env bootstrap logic into a testable helper module - Added `CODEX_CLI_ENV_AUTOLOAD=0` support to disable CLI env auto-loading - Added banner controls: @@ -181,16 +181,16 @@ Primary objectives (priority order): - `CODEX_CLI_ENV_BANNER_VERBOSE=1` to show env source paths - JSON-mode (`--json`) banner suppression by default (override with `CODEX_CLI_ENV_BANNER_FORCE=1`) - Default banner is now sanitized (generic local `.env` count, no file paths) and includes a local-sensitive note when `src/cli/.env` is loaded -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/src/cli/index.ts` +- Updated `src/cli/index.ts` - Uses helper module for env bootstrap and banner decisions - Moves env bootstrap banner output to `stderr` (not `stdout`) to avoid contaminating command output streams -- Added tests in `/Users/chrisdukes/LocalProjects/codex-synaptic/tests/cli/env-bootstrap.test.ts` +- Added tests in `tests/cli/env-bootstrap.test.ts` - env parser no-override behavior - autoload toggle behavior - JSON-mode banner suppression / forced banner override - sanitized vs verbose banner formatting - bootstrap file precedence/order semantics -- Updated UAT runbook notes in `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` +- Updated UAT runbook notes in `docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` - documents `CODEX_CLI_ENV_AUTOLOAD=0`, stderr banner behavior, and `CODEX_CLI_ENV_BANNER_VERBOSE=1` caution #### Verification evidence @@ -226,7 +226,7 @@ Primary objectives (priority order): #### What changed -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/package.json` +- Updated `package.json` - Added a `files` whitelist to explicitly control package contents instead of relying on `.gitignore` fallback behavior. - Kept compiled/runtime assets and key metadata: - `dist/` @@ -277,10 +277,10 @@ Primary objectives (priority order): #### What changed -- Updated direct dependencies in `/Users/chrisdukes/LocalProjects/codex-synaptic/package.json` +- Updated direct dependencies in `package.json` - `@openai/agents`: `^0.1.10 -> ^0.1.11` (safe patch upgrade) - `js-yaml`: `^4.1.0 -> ^4.1.1` (safe patch upgrade; resolves direct moderate advisory) -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/package-lock.json` +- Updated `package-lock.json` - Pulled patched transitive chain under `@openai/agents`, including: - `@modelcontextprotocol/sdk@1.27.0` - `express@5.2.1` @@ -359,7 +359,7 @@ Primary objectives (priority order): #### Next exact step -- Chunk 7: execute the full UAT smoke from `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md`, capture dated evidence artifacts, and produce a final UAT PASS/FAIL/BLOCKED report (likely `BLOCKED` if GHCR auth remains unavailable). +- Chunk 7: execute the full UAT smoke from `docs/uat/CODEX_MACOS_UAT_RUNBOOK.md`, capture dated evidence artifacts, and produce a final UAT PASS/FAIL/BLOCKED report (likely `BLOCKED` if GHCR auth remains unavailable). ### Chunk 7 — Full UAT Smoke + Final Report @@ -368,10 +368,10 @@ Primary objectives (priority order): #### What changed -- Captured UAT smoke evidence under `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/evidence/2026-02-24/` +- Captured UAT smoke evidence under `docs/uat/evidence/2026-02-24/` - Includes step command/output/exit artifacts, `doctor.strict.json`, `launch.strict.json`, `codex.mcp.list.json`, and status matrix `_status.tsv` - Added final UAT report: - - `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/UAT_FINAL_REPORT.md` + - `docs/uat/UAT_FINAL_REPORT.md` - Secret hygiene remediation during evidence capture: - Redacted 1 secret-like value in `codex.mcp.list.json` (`bearer_token_env_var` field contained a token-looking value for an unrelated MCP entry) - Original secret value was not copied into tracker/report/chat @@ -419,7 +419,7 @@ Primary objectives (priority order): - Unblock + rerun: 1. Perform interactive `docker login ghcr.io` (or configure Docker credential helper) with access to required MCP images. 2. Fix `launch --strict --json` stdout purity (logger output should not precede JSON on stdout). - 3. Re-run Chunk 7 smoke (runbook steps 4-9 minimum, preferably full run) with a fresh dated evidence folder and update `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/UAT_FINAL_REPORT.md`. + 3. Re-run Chunk 7 smoke (runbook steps 4-9 minimum, preferably full run) with a fresh dated evidence folder and update `docs/uat/UAT_FINAL_REPORT.md`. ### Chunk 7 Follow-up A — GHCR Access Recheck (Post Interactive Login) @@ -460,12 +460,12 @@ Primary objectives (priority order): #### What changed -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/src/cli/launch.ts` +- Updated `src/cli/launch.ts` - Added an internal launch option (`suppressInfoConsoleLogs`) and a scoped helper that temporarily raises logger console threshold to `WARN` while MCP services are started. - This suppresses info-level `Logger` console output (for example `Starting service mcp-filesystem`) during JSON launch execution while preserving warnings/errors to stderr and retaining file logging. -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/src/cli/index.ts` +- Updated `src/cli/index.ts` - `launch` command now enables the scoped suppression automatically when `--json` is used. -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/tests/cli/launch.test.ts` +- Updated `tests/cli/launch.test.ts` - Added a targeted regression test that simulates an info-level logger emission during MCP startup and asserts no `console.info` leakage when JSON-safe suppression is enabled. #### Verification evidence @@ -502,13 +502,13 @@ Primary objectives (priority order): #### What changed -- Captured a fresh rerun evidence set under `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/evidence/2026-02-24-rerun-1/` +- Captured a fresh rerun evidence set under `docs/uat/evidence/2026-02-24-rerun-1/` - Reproduced the full runbook step set (`01-13`) with command/output/exit artifacts and JSON outputs. - Added explicit post-login GHCR pull verification artifacts in the same evidence folder: - `00a-ghcr-pull-filesystem.*` - `00b-ghcr-pull-playwright.*` - `00c-ghcr-pull-desktop-commander.*` -- Refreshed `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/UAT_FINAL_REPORT.md` +- Refreshed `docs/uat/UAT_FINAL_REPORT.md` - Updated verdict remains `BLOCKED` - `env docker-login` now recorded as `PASS` - primary blocker updated to image `not found` @@ -554,13 +554,13 @@ Primary objectives (priority order): #### What changed -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/src/env/service-manager.ts` +- Updated `src/env/service-manager.ts` - `mcp-playwright` image reference changed from legacy GHCR wrapper to canonical Docker Hub MCP image: - `ghcr.io/context-labs/playwright-mcp:latest` -> `mcp/playwright:latest` -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/docker/mcp/docker-compose.playwright.yml` +- Updated `docker/mcp/docker-compose.playwright.yml` - Image changed to `mcp/playwright:latest` - Added `--host 0.0.0.0` to the container command so the HTTP/SSE server binds to the container interface (required for compose port publishing) -- Updated `/Users/chrisdukes/LocalProjects/codex-synaptic/docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` +- Updated `docs/uat/CODEX_MACOS_UAT_RUNBOOK.md` - Added a dated upstream image migration note documenting current canonical images and the transport compatibility constraint - Updated Docker-login step guidance to distinguish private registry auth from public Docker Hub `mcp/*` images - Added `not found` as a first-class image drift/deprecation blocker indicator diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index f3e4dc1..69def5e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,13 +1,22 @@ -import { spawnSync, type SpawnSyncReturns } from 'child_process'; +import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { serviceManager, type ServiceStatus } from '../env/service-manager.js'; +import { BridgeError, ErrorCode } from '../core/errors.js'; -export const DEFAULT_MCP_PROFILES = [ - 'mcp-filesystem', - 'mcp-playwright', - 'mcp-desktop-commander' -] as const; +export enum MCPProfile { + Filesystem = 'mcp-filesystem', + Playwright = 'mcp-playwright', + DesktopCommander = 'mcp-desktop-commander' +} + +export const DEFAULT_MCP_PROFILES = Object.values(MCPProfile); + +export type SpawnCommandResult = { + status: number | null; + stdout: string; + stderr: string; +}; export interface DoctorCheck { id: string; @@ -36,12 +45,12 @@ export interface DoctorOptions { } export interface DoctorDependencies { - fileExists?: (path: string) => boolean; + fileExists?: (path: string) => boolean | Promise; spawnCommand?: ( command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'>; + ) => Promise; getServiceStatus?: (name: string) => Promise; getCodexRegistration?: (name: string) => { codexName: string; url: string } | null; registriesForProfiles?: (names: string[]) => string[]; @@ -82,7 +91,11 @@ function parseCodexMcpNames(payload: unknown): string[] { } } - throw new Error('Unsupported JSON format returned by `codex mcp list --json`.'); + throw new BridgeError( + ErrorCode.MCP_ERROR, + 'Unsupported JSON format returned by `codex mcp list --json`.', + { retryable: false } + ); } export function parseProfileList(input: string | string[] | undefined, fallback = [...DEFAULT_MCP_PROFILES]): string[] { @@ -130,7 +143,37 @@ 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)); + ?? ((command, args, spawnOptions) => new Promise((resolve) => { + const child = spawn(command, args, { + cwd: spawnOptions.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + if (child.stdout) { + child.stdout.setEncoding(spawnOptions.encoding); + } + if (child.stderr) { + child.stderr.setEncoding(spawnOptions.encoding); + } + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('close', (status) => { + resolve({ status, stdout, stderr }); + }); + child.on('error', (error) => { + resolve({ + status: 1, + stdout, + stderr: stderr || `${error.name}: ${error.message}` + }); + }); + })); const getServiceStatus = deps.getServiceStatus ?? ((name: string) => serviceManager.status(name)); const getCodexRegistration = deps.getCodexRegistration ?? ((name: string) => serviceManager.codexRegistration(name)); @@ -140,7 +183,7 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende const checks: DoctorCheck[] = []; const distCliPath = join(cwd, 'dist', 'cli', 'index.js'); - const distExists = fileExists(distCliPath); + const distExists = await fileExists(distCliPath); checks.push({ id: 'repo.cli_build_artifact', ok: distExists, @@ -149,7 +192,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' }); @@ -167,7 +210,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' }); @@ -183,7 +226,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' }); @@ -217,38 +260,47 @@ export async function runDoctor(options: DoctorOptions = {}, deps: DoctorDepende } 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) { - if (registriesForProfiles([profileName]).length > 0) { - remediationParts.push(`codex-synaptic env docker-login ${profileName}`); + try { + 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(' | ')}`; } - 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 remediationParts: string[] = []; + if (!status.running || !healthy) { + if (registriesForProfiles([profileName]).length > 0) { + remediationParts.push(`codex-synaptic env docker-login ${profileName}`); + } + 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 + } + }); + } catch (error) { + checks.push({ + id: `mcp.${profileName}`, + ok: false, + details: `invalid profile: ${(error as Error).message}`, + remediation: `Verify MCP profile name "${profileName}" and retry.` + }); + } } const passed = checks.filter((check) => check.ok).length; diff --git a/src/cli/env-bootstrap.ts b/src/cli/env-bootstrap.ts index 9ce4d43..f274f63 100644 --- a/src/cli/env-bootstrap.ts +++ b/src/cli/env-bootstrap.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "fs"; +import { promises as fs } from "fs"; import { relative, resolve } from "path"; function parseBooleanFlag( @@ -57,16 +57,18 @@ export function shouldShowCliEnvBanner( return parseBooleanFlag(env.CODEX_CLI_ENV_BANNER, true); } -export function loadEnvFile( +export async function loadEnvFile( filePath: string, env: NodeJS.ProcessEnv = process.env, -): boolean { - if (!existsSync(filePath)) { +): Promise { + try { + await fs.access(filePath); + } catch { return false; } try { - const content = readFileSync(filePath, "utf8"); + const content = await fs.readFile(filePath, "utf8"); const lines = content.split(/\r?\n/); let applied = false; @@ -114,12 +116,12 @@ export function loadEnvFile( } } -export function bootstrapCliEnv( +export async function bootstrapCliEnv( options: { cwd?: string; env?: NodeJS.ProcessEnv; } = {}, -): string[] { +): Promise { const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; const sources: string[] = []; @@ -136,7 +138,7 @@ export function bootstrapCliEnv( continue; } seen.add(candidate); - if (loadEnvFile(candidate, env)) { + if (await loadEnvFile(candidate, env)) { sources.push(candidate); } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 638bcee..d9bd01a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -87,9 +87,7 @@ import { shouldShowCliEnvBanner } from './env-bootstrap.js'; -const loadedEnvSources = shouldAutoLoadCliEnv(process.env) - ? bootstrapCliEnv({ cwd: process.cwd(), env: process.env }) - : []; +let loadedEnvSources: string[] = []; const program = new Command(); const session = CliSession.getInstance(); @@ -105,14 +103,6 @@ if (cliSilent) { rootLogger.setConsoleLevel(LogLevel.ERROR); } -if (loadedEnvSources.length && shouldShowCliEnvBanner({ env: process.env, cliSilent, argv: process.argv })) { - const lines = buildCliEnvBootstrapMessages(loadedEnvSources, { cwd: process.cwd(), env: process.env }); - for (const line of lines) { - const colorize = line.startsWith('🔒') ? chalk.yellow : chalk.gray; - process.stderr.write(`${colorize(line)}\n`); - } -} - type BackgroundJob = { id: number; command: string; @@ -4396,7 +4386,7 @@ envCmd } for (const registry of registries) { - serviceManager.dockerLogin(registry); + await serviceManager.dockerLogin(registry); console.log(chalk.green(`✅ Docker auth completed for ${registry}`)); } })); @@ -5095,6 +5085,18 @@ program.exitOverride(); // Intercept commands with --codex flag and pass through to Codex CLI // Similar to claude-flow's --claude flag (async () => { + if (shouldAutoLoadCliEnv(process.env)) { + loadedEnvSources = await bootstrapCliEnv({ cwd: process.cwd(), env: process.env }); + } + + if (loadedEnvSources.length && shouldShowCliEnvBanner({ env: process.env, cliSilent, argv: process.argv })) { + const lines = buildCliEnvBootstrapMessages(loadedEnvSources, { cwd: process.cwd(), env: process.env }); + for (const line of lines) { + const colorize = line.startsWith('🔒') ? chalk.yellow : chalk.gray; + process.stderr.write(`${colorize(line)}\n`); + } + } + const args = process.argv.slice(2); // Check if --codex flag is present (but not in hive-mind spawn or cheat which have their own --codex handling) diff --git a/src/cli/launch.ts b/src/cli/launch.ts index 322ddf6..2eae7af 100644 --- a/src/cli/launch.ts +++ b/src/cli/launch.ts @@ -1,5 +1,5 @@ -import { spawnSync, type SpawnSyncReturns } from 'child_process'; -import { existsSync } from 'fs'; +import { spawn } from 'child_process'; +import { access } from 'fs/promises'; import { join } from 'path'; import { getBackgroundStatus, @@ -10,6 +10,7 @@ import { serviceManager, type EnsureServiceOptions } from '../env/service-manage import { collectDoctorRemediations, DEFAULT_MCP_PROFILES, + type SpawnCommandResult, runDoctor, type DoctorDependencies, type DoctorOptions, @@ -30,7 +31,12 @@ export interface LaunchReport { ok: boolean; steps: LaunchStep[]; doctor: DoctorReport; - nextAction: 'continue' | 'stop'; + nextAction: LaunchNextAction; +} + +export enum LaunchNextAction { + Continue = 'continue', + Stop = 'stop' } export interface LaunchOptions { @@ -64,9 +70,39 @@ function normalizeSpawn( command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'> { + ) => Promise { return deps.spawnCommand - ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + ?? ((command, args, spawnOptions) => new Promise((resolve) => { + const child = spawn(command, args, { + cwd: spawnOptions.cwd, + stdio: ['ignore', 'pipe', 'pipe'] + }); + if (child.stdout) { + child.stdout.setEncoding(spawnOptions.encoding); + } + if (child.stderr) { + child.stderr.setEncoding(spawnOptions.encoding); + } + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('close', (status) => { + resolve({ status, stdout, stderr }); + }); + child.on('error', (error) => { + resolve({ + status: 1, + stdout, + stderr: stderr || `${error.name}: ${error.message}` + }); + }); + })); } function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { @@ -75,7 +111,7 @@ function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): Lau ok, steps, doctor: doctorReport, - nextAction: ok ? 'continue' : 'stop' + nextAction: ok ? LaunchNextAction.Continue : LaunchNextAction.Stop }; } @@ -146,7 +182,14 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende ? [...options.mcpProfiles] : [...DEFAULT_MCP_PROFILES]; - const fileExists = deps.fileExists ?? existsSync; + const fileExists = deps.fileExists ?? (async (path: string) => { + try { + await access(path); + return true; + } catch { + return false; + } + }); const spawnCommand = normalizeSpawn(deps); const startBackground = deps.startBackground ?? (() => startBackgroundSystem()); const readBackgroundStatus = deps.getBackgroundStatus ?? (() => getBackgroundStatus()); @@ -166,32 +209,32 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende }; 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' - }); + const distExists = await fileExists(distCliPath); - 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`.' - }; - })() - : { + let preflightStep: LaunchStep; + if (distExists) { + const cliHelp = await spawnCommand('node', [distCliPath, '--help'], { + cwd, + encoding: 'utf8' + }); + preflightStep = { + 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`.' + }; + } else { + preflightStep = { id: 'repo.preflight', ok: false, details: `Missing ${distCliPath}`, remediation: 'Run `npm run build`.' }; + } { const stop = appendStep(preflightStep); @@ -200,26 +243,27 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende } } - const codexAuthStep: LaunchStep = options.skipCodexAuth - ? { + let codexAuthStep: LaunchStep; + if (options.skipCodexAuth) { + codexAuthStep = { 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`.' - }; - })(); + }; + } else { + const loginStatus = await spawnCommand('codex', ['login', 'status'], { + cwd, + encoding: 'utf8' + }); + const stdout = loginStatus.stdout?.trim() || ''; + const ok = loginStatus.status === 0 && !/not logged in/i.test(stdout); + codexAuthStep = { + 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); @@ -340,7 +384,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' }); @@ -352,7 +396,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' }); @@ -419,7 +463,8 @@ export async function runLaunch(options: LaunchOptions = {}, deps: LaunchDepende fileExists, spawnCommand, getServiceStatus: deps.getServiceStatus, - getCodexRegistration: deps.getCodexRegistration + getCodexRegistration: deps.getCodexRegistration, + registriesForProfiles: deps.registriesForProfiles } ); diff --git a/src/env/service-manager.ts b/src/env/service-manager.ts index 46611d4..733f549 100644 --- a/src/env/service-manager.ts +++ b/src/env/service-manager.ts @@ -1,6 +1,7 @@ import { execSync, spawn } from 'child_process'; import { createConnection } from 'net'; import { setTimeout as sleep } from 'timers/promises'; +import { CodexSynapticError, ErrorCode } from '../core/errors.js'; import { Logger } from '../core/logger.js'; export type FilesystemAccessMode = 'read-only' | 'controlled-write'; @@ -322,14 +323,25 @@ class ServiceManager { return Array.from(registries); } - dockerLogin(registry: string): void { + async dockerLogin(registry: string): Promise { 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' }); + await new Promise((resolve, reject) => { + const child = spawn('docker', ['login', normalized], { stdio: 'inherit' }); + child.on('error', (error) => { + reject(error); + }); + child.on('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`docker login exited with status ${code ?? 'unknown'}`)); + }); + }); } private registryForImage(image: string): string | null { @@ -427,8 +439,21 @@ class ServiceManager { const truncatedOutput = output.length > 500 ? `${output.slice(0, 500)}…` : output; const exitLabel = exitStatus === null ? 'unknown' : String(exitStatus); - return new Error( - `${diagnosis} (exit=${exitLabel}, compose=${cmd}). ${remediation} Raw docker output: ${truncatedOutput}` + return new CodexSynapticError( + ErrorCode.BRIDGE_ERROR, + `${diagnosis} (exit=${exitLabel}, compose=${cmd}). ${remediation} Raw docker output: ${truncatedOutput}`, + { + code: 'COMPOSE_START_FAILED', + diagnosis, + remediation, + exitStatus, + composeCmd: cmd, + output: truncatedOutput, + images, + serviceName: name, + profile: profile.composeFile + }, + false ); } diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 786b8d7..afc9408 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: '' }; } @@ -155,7 +155,8 @@ describe('runDoctor', () => { throw new Error(`Unexpected command: ${command} ${args.join(' ')}`); }, getServiceStatus: async () => serviceStatus({ running: false, healthy: false }), - getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }) + getCodexRegistration: () => ({ codexName: 'filesystem-local', url: 'http://localhost:7040' }), + registriesForProfiles: () => ['ghcr.io'] }; const report = await runDoctor( @@ -177,7 +178,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/env-bootstrap.test.ts b/tests/cli/env-bootstrap.test.ts index 6ae7551..4e48ab2 100644 --- a/tests/cli/env-bootstrap.test.ts +++ b/tests/cli/env-bootstrap.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished } from "vitest"; import { bootstrapCliEnv, buildCliEnvBootstrapMessages, @@ -11,8 +11,9 @@ import { } from "../../src/cli/env-bootstrap"; describe("cli env bootstrap helper", () => { - it("does not override existing environment variables", () => { + it("does not override existing environment variables", async () => { const tempDir = mkdtempSync(join(tmpdir(), "codex-env-bootstrap-")); + onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })); const envPath = join(tempDir, ".env"); writeFileSync(envPath, "EXISTING_KEY=from-file\nNEW_KEY=from-file\n"); @@ -20,12 +21,10 @@ describe("cli env bootstrap helper", () => { EXISTING_KEY: "from-env", }; - const applied = loadEnvFile(envPath, env); + const applied = await loadEnvFile(envPath, env); expect(applied).toBe(true); expect(env.EXISTING_KEY).toBe("from-env"); expect(env.NEW_KEY).toBe("from-file"); - - rmSync(tempDir, { recursive: true, force: true }); }); it("autoload is enabled by default and can be disabled explicitly", () => { @@ -81,8 +80,9 @@ describe("cli env bootstrap helper", () => { expect(verboseMessages[0]).toContain("src/cli/.env"); }); - it("bootstraps candidate env files in precedence order without duplicates", () => { + it("bootstraps candidate env files in precedence order without duplicates", async () => { const tempDir = mkdtempSync(join(tmpdir(), "codex-env-bootstrap-")); + onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })); writeFileSync(join(tempDir, ".env"), "BASE_KEY=base\n"); writeFileSync( join(tempDir, ".env.local"), @@ -95,7 +95,7 @@ describe("cli env bootstrap helper", () => { }); const env: NodeJS.ProcessEnv = {}; - const sources = bootstrapCliEnv({ cwd: tempDir, env }); + const sources = await bootstrapCliEnv({ cwd: tempDir, env }); expect(sources.map((source) => source.replace(`${tempDir}/`, ""))).toEqual([ ".env", @@ -105,7 +105,5 @@ describe("cli env bootstrap helper", () => { expect(env.BASE_KEY).toBe("base"); expect(env.LOCAL_KEY).toBe("local"); expect(env.CLI_KEY).toBe("cli"); - - rmSync(tempDir, { recursive: true, force: true }); }); }); diff --git a/tests/cli/launch.test.ts b/tests/cli/launch.test.ts index 4f73c63..c2cb86d 100644 --- a/tests/cli/launch.test.ts +++ b/tests/cli/launch.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { runLaunch, type LaunchDependencies } from '../../src/cli/launch'; +import { LaunchNextAction, runLaunch, type LaunchDependencies } from '../../src/cli/launch'; import type { DoctorReport } from '../../src/cli/doctor'; import { Logger } from '../../src/core/logger'; const passingDoctorReport: DoctorReport = { ok: true, - summary: { passed: 6, failed: 0, total: 6 }, + summary: { passed: 0, failed: 0, total: 0 }, checks: [] }; @@ -16,7 +16,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')) { @@ -64,7 +64,7 @@ describe('runLaunch', () => { ); expect(report.ok).toBe(true); - expect(report.nextAction).toBe('continue'); + expect(report.nextAction).toBe(LaunchNextAction.Continue); expect(report.steps.map((step) => step.id)).toEqual([ 'repo.preflight', 'codex.auth', @@ -97,7 +97,7 @@ describe('runLaunch', () => { ); expect(report.ok).toBe(false); - expect(report.nextAction).toBe('stop'); + expect(report.nextAction).toBe(LaunchNextAction.Stop); expect(report.steps).toHaveLength(1); expect(report.steps[0].id).toBe('repo.preflight'); expect(report.doctor.summary.total).toBe(0); @@ -114,13 +114,14 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (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 }), + registriesForProfiles: () => ['ghcr.io'], ensureService: async () => { throw new Error('docker compose timeout'); }, @@ -129,7 +130,7 @@ describe('runLaunch', () => { ); expect(report.ok).toBe(false); - expect(report.nextAction).toBe('stop'); + expect(report.nextAction).toBe(LaunchNextAction.Stop); const mcpStep = report.steps.find((step) => step.id === 'mcp.up'); expect(mcpStep?.ok).toBe(false); expect(mcpStep?.details).toContain('mcp-filesystem'); @@ -152,7 +153,7 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; } @@ -175,7 +176,7 @@ describe('runLaunch', () => { ); expect(report.ok).toBe(false); - expect(report.nextAction).toBe('stop'); + expect(report.nextAction).toBe(LaunchNextAction.Stop); expect(doctorCalled).toBe(false); const registrationStep = report.steps.find((step) => step.id === 'mcp.codex_register'); expect(registrationStep?.ok).toBe(false); @@ -200,7 +201,7 @@ describe('runLaunch', () => { }, { fileExists: () => true, - spawnCommand: (command, args) => { + spawnCommand: async (command, args) => { if (command === 'node' && args.includes('--help')) { return { status: 0, stdout: 'ok', stderr: '' }; }