diff --git a/.codex-synaptic/memory.db b/.codex-synaptic/memory.db index 8308945..427c870 100644 Binary files a/.codex-synaptic/memory.db and b/.codex-synaptic/memory.db differ 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 4190d1d..03dd443 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 @@ -89,6 +92,9 @@ Non-interactive CLI commands run in one-shot mode by default (the process exits # 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 5fbc20b..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,29 +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 -If you need to keep the foreground process alive for debugging, run with `CODEX_CLI_AUTO_SHUTDOWN=0`. +```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 @@ -39,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..4cc9d8c --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,304 @@ +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. + * 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, + 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', + '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 } + ) => Promise, '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 ?? spawnAsync; + 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 = await 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 = await 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 = await 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 d87fa0a..3ff1231 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -32,7 +32,8 @@ import type { CodexPromptEnvelope, ContextLogEntry } from '../types/codex-context.js'; -import { RetryManager, DaemonConflictError } from '../core/errors.js'; +import { CliGateError, ErrorCode, RetryManager } from '../core/errors.js'; +import { DaemonConflictError } from '../core/errors.js'; import { HiveMindYamlFormatter } from '../utils/yaml-output.js'; import { parseFileContent, parseJsonInput, loadFileThroughFeedforward } from './feedforward.js'; import { InstructionParser } from '../instructions/index.js'; @@ -77,6 +78,11 @@ import { type QuotaOptions } from './tenant-quota-helpers.js'; import { + DEFAULT_MCP_PROFILES, + parseProfileList, + runDoctor +} from './doctor.js'; +import { collectLaunchRemediations, runLaunch } from './launch.js'; executeGoapWorkflow, executeTaskWithConsensus, collectExecutionResults, @@ -5172,6 +5178,34 @@ async function execCodexCommand(args: string[], timeoutMs = 10000): Promise<{ st } } +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') @@ -5206,6 +5240,95 @@ 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 CliGateError( + ErrorCode.LAUNCH_GATE_FAILURE, + 'Launch failed one or more readiness gates.', + { strict, report } + ); + } + })); + const doctorCmd = decorateCommandHelp( program .command('doctor') @@ -5241,7 +5364,7 @@ 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) @@ -5273,7 +5396,8 @@ doctorCmd let codexMcpNames = new Set(); const codexMcpList = spawnSync('codex', ['mcp', 'list', '--json'], { cwd: process.cwd(), - encoding: 'utf8' + mcpProfiles: profileNames, + skipCodexAuth: Boolean(options.skipCodexAuth) }); if (codexMcpList.status === 0) { try { diff --git a/src/cli/launch.ts b/src/cli/launch.ts new file mode 100644 index 0000000..72935f2 --- /dev/null +++ b/src/cli/launch.ts @@ -0,0 +1,443 @@ +import { spawn, 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'; +import { BridgeError, ErrorCode } from '../core/errors.js'; + +/** + * 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, + 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; + 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 } + ) => Promise, 'status' | 'stdout' | 'stderr'>> { + return deps.spawnCommand ?? spawnAsync; +} + +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 + ? await (async () => { + const cliHelp = await 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).' + } + : await (async () => { + 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); + 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 = await 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 = await 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 BridgeError( + ErrorCode.MCP_ERROR, + `codex mcp add failed for ${registration.codexName}: ${stderr || add.stdout?.trim() || 'unknown error'}`, + { + registration: registration.codexName, + stderr, + stdout: add.stdout + } + ); + } + + 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) { + const bridgeError = error instanceof BridgeError ? error : null; + 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`, + metadata: bridgeError + ? { + code: bridgeError.code, + context: bridgeError.context + } + : undefined + }; + } + } + + { + 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/core/errors.ts b/src/core/errors.ts index 2585f2e..ac6ffd3 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -7,6 +7,8 @@ export enum ErrorCode { SYSTEM_NOT_INITIALIZED = 'SYSTEM_NOT_INITIALIZED', SYSTEM_SHUTDOWN = 'SYSTEM_SHUTDOWN', SYSTEM_OVERLOAD = 'SYSTEM_OVERLOAD', + LAUNCH_GATE_FAILURE = 'LAUNCH_GATE_FAILURE', + DOCTOR_CHECKS_FAILED = 'DOCTOR_CHECKS_FAILED', // Agent errors AGENT_NOT_FOUND = 'AGENT_NOT_FOUND', @@ -86,6 +88,17 @@ export class SystemError extends CodexSynapticError { } } +export class CliGateError extends CodexSynapticError { + constructor( + code: ErrorCode.LAUNCH_GATE_FAILURE | ErrorCode.DOCTOR_CHECKS_FAILED, + message: string, + context?: Record + ) { + 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/src/env/service-manager.ts b/src/env/service-manager.ts index fea107d..5d423fc 100644 --- a/src/env/service-manager.ts +++ b/src/env/service-manager.ts @@ -20,6 +20,8 @@ export interface ServiceProfile { composeFile: string; services?: string[]; port?: number; + dockerImages?: string[]; + dockerRegistries?: string[]; requiredEnv?: string[]; codexName?: string; healthcheck?: { @@ -83,6 +85,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' }, @@ -91,6 +94,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' }, @@ -99,6 +103,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': { @@ -106,6 +111,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': { @@ -113,6 +119,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': { @@ -120,6 +127,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' }, @@ -128,6 +136,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' } @@ -279,6 +288,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..fe86ca8 --- /dev/null +++ b/tests/cli/doctor.test.ts @@ -0,0 +1,216 @@ +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: async (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('fails codex auth check when codex login status returns non-zero', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: async (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' }, + 'mcp-playwright': { codexName: 'playwright-local', url: 'http://localhost:7030' } + }; + + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: async (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: async (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'); + }); + + it('fails codex MCP parsing checks when codex mcp list returns malformed JSON', async () => { + const deps: DoctorDependencies = { + fileExists: () => true, + spawnCommand: async (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 new file mode 100644 index 0000000..cbd2320 --- /dev/null +++ b/tests/cli/launch.test.ts @@ -0,0 +1,181 @@ +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: async (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: 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 }), + 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'); + }); + + 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: async (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'); + }); +}); diff --git a/tests/e2e/cli-smoke.test.ts b/tests/e2e/cli-smoke.test.ts index 502c36e..808aa0c 100644 --- a/tests/e2e/cli-smoke.test.ts +++ b/tests/e2e/cli-smoke.test.ts @@ -127,12 +127,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'); + }); });