diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 27ad23b..4cc9d8c 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,8 +1,53 @@ -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. + * 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', @@ -41,7 +86,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; } @@ -128,8 +173,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 - ?? ((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)); @@ -146,7 +190,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 +208,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 +224,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..72935f2 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,51 @@ import { } 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; @@ -62,9 +107,8 @@ function normalizeSpawn( command: string, args: string[], options: { cwd: string; encoding: BufferEncoding } - ) => Pick, 'status' | 'stdout' | 'stderr'> { - return deps.spawnCommand - ?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions)); + ) => Promise, 'status' | 'stdout' | 'stderr'>> { + return deps.spawnCommand ?? spawnAsync; } function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport { @@ -148,8 +192,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 +229,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 +339,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 +351,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: '' }; }