Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<SpawnSyncReturns<string>, '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',
Expand Down Expand Up @@ -41,7 +86,7 @@ export interface DoctorDependencies {
command: string,
args: string[],
options: { cwd: string; encoding: BufferEncoding }
) => Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'stderr'>;
) => Promise<Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'stderr'>>;
getServiceStatus?: (name: string) => Promise<ServiceStatus>;
getCodexRegistration?: (name: string) => { codexName: string; url: string } | null;
}
Expand Down Expand Up @@ -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));
Expand All @@ -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'
});
Expand All @@ -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'
});
Expand All @@ -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'
});
Expand Down
64 changes: 54 additions & 10 deletions src/cli/launch.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Pick<SpawnSyncReturns<string>, '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;
Expand Down Expand Up @@ -62,9 +107,8 @@ function normalizeSpawn(
command: string,
args: string[],
options: { cwd: string; encoding: BufferEncoding }
) => Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'stderr'> {
return deps.spawnCommand
?? ((command, args, spawnOptions) => spawnSync(command, args, spawnOptions));
) => Promise<Pick<SpawnSyncReturns<string>, 'status' | 'stdout' | 'stderr'>> {
return deps.spawnCommand ?? spawnAsync;
}

function buildLaunchReport(steps: LaunchStep[], doctorReport: DoctorReport): LaunchReport {
Expand Down Expand Up @@ -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'
});
Expand Down Expand Up @@ -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'
});
Expand Down Expand Up @@ -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'
});
Expand All @@ -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'
});
Expand Down
10 changes: 5 additions & 5 deletions tests/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: '' };
}
Expand Down Expand Up @@ -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: '' };
}
Expand Down Expand Up @@ -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: '' };
}
Expand Down Expand Up @@ -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: '' };
}
Expand Down
6 changes: 3 additions & 3 deletions tests/cli/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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: '' };
}
Expand Down Expand Up @@ -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: '' };
}
Expand Down
Loading