From 8cf92095fb9aef31bb1d68d56108266642f32ba7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 20 Aug 2026 01:44:46 +0000 Subject: [PATCH] fix(dev): support Node tool shims on Windows --- .../dev/__tests__/codezip-dev-server.test.ts | 76 ++++++++++++++++--- src/cli/operations/dev/codezip-dev-server.ts | 31 +++++--- .../dev/web-ui/__tests__/run-web-ui.test.ts | 67 ++++++++++++++++ src/cli/operations/dev/web-ui/run-web-ui.ts | 7 +- src/lib/utils/__tests__/subprocess.test.ts | 1 + src/lib/utils/subprocess.ts | 4 +- 6 files changed, 162 insertions(+), 24 deletions(-) create mode 100644 src/cli/operations/dev/web-ui/__tests__/run-web-ui.test.ts diff --git a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts index 176aeb1d2..c582305df 100644 --- a/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/codezip-dev-server.test.ts @@ -7,10 +7,15 @@ import { existsSync } from 'fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockSpawn = vi.fn(); +const mockRunSubprocessCapture = vi.fn(); +const platformState = vi.hoisted(() => ({ isWindows: false })); vi.mock('child_process', () => ({ spawn: (...args: unknown[]) => mockSpawn(...args), spawnSync: vi.fn(() => ({ status: 0, stdout: Buffer.from(''), stderr: Buffer.from('') })), })); +vi.mock('../../../../lib/utils/subprocess', () => ({ + runSubprocessCapture: (...args: unknown[]) => mockRunSubprocessCapture(...args), +})); vi.mock('fs', () => ({ existsSync: vi.fn(() => true), @@ -21,7 +26,11 @@ const mockExistsSync = vi.mocked(existsSync); vi.mock('../../../../lib/utils/platform', () => ({ getVenvExecutable: (venvPath: string, executable: string) => `${venvPath}/bin/${executable}`, - isWindows: false, + getShellCommand: () => 'cmd', + getShellArgs: (command: string) => ['/c', command], + get isWindows() { + return platformState.isWindows; + }, })); function createMockChildProcess() { @@ -38,8 +47,13 @@ const defaultOptions: DevServerOptions = { port: 8080, envVars: { MY_KEY: 'secre describe('CodeZipDevServer spawn config', () => { beforeEach(() => { + platformState.isWindows = false; mockSpawn.mockClear(); mockSpawn.mockReturnValue(createMockChildProcess()); + mockRunSubprocessCapture.mockReset(); + mockRunSubprocessCapture.mockResolvedValue({ code: 0, stdout: '', stderr: '', signal: null }); + vi.mocked(mockCallbacks.onLog).mockClear(); + vi.mocked(mockCallbacks.onExit).mockClear(); }); afterEach(() => vi.restoreAllMocks()); @@ -155,6 +169,28 @@ describe('CodeZipDevServer spawn config', () => { expect(env.LOCAL_DEV).toBe('1'); }); + it('TypeScript HTTP: runs npx through cmd on Windows', async () => { + platformState.isWindows = true; + const config: DevConfig = { + agentName: 'TsAgent', + module: 'src/main.ts', + directory: 'C:\\project\\app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + await server.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'cmd', + ['/c', 'npx tsx watch src/main.ts'], + expect.objectContaining({ cwd: 'C:\\project\\app', detached: false }) + ); + }); + it('TypeScript: installs node dependencies when node_modules missing', async () => { mockExistsSync.mockImplementation((p: unknown) => { const s = String(p); @@ -163,13 +199,6 @@ describe('CodeZipDevServer spawn config', () => { if (s.endsWith('yarn.lock')) return false; return true; }); - mockSpawnSync.mockClear(); - mockSpawnSync.mockReturnValue({ - status: 0, - stdout: Buffer.from(''), - stderr: Buffer.from(''), - } as any); - const config: DevConfig = { agentName: 'TsAgent', module: 'main.ts', @@ -183,10 +212,37 @@ describe('CodeZipDevServer spawn config', () => { const server = new CodeZipDevServer(config, defaultOptions); await server.start(); - expect(mockSpawnSync).toHaveBeenCalledWith('npm', ['install'], expect.objectContaining({ cwd: '/project/app' })); + expect(mockRunSubprocessCapture).toHaveBeenCalledWith('npm', ['install'], { cwd: '/project/app' }); mockExistsSync.mockImplementation(() => true); }); + it('TypeScript: reports the process creation error when dependency installation cannot start', async () => { + mockExistsSync.mockImplementation((p: unknown) => !String(p).endsWith('node_modules')); + mockRunSubprocessCapture.mockResolvedValue({ + status: null, + code: -1, + stdout: '', + stderr: 'spawn npm ENOENT', + signal: null, + }); + const config: DevConfig = { + agentName: 'TsAgent', + module: 'main.ts', + directory: '/project/app', + hasConfig: true, + isPython: false, + buildType: 'CodeZip', + protocol: 'HTTP', + }; + + const server = new CodeZipDevServer(config, defaultOptions); + const child = await server.start(); + + expect(child).toBeNull(); + expect(mockCallbacks.onLog).toHaveBeenCalledWith('error', 'Failed to install Node dependencies: spawn npm ENOENT'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + it('TypeScript: skips install when node_modules exists', async () => { mockExistsSync.mockImplementation(() => true); mockSpawnSync.mockClear(); @@ -204,7 +260,7 @@ describe('CodeZipDevServer spawn config', () => { const server = new CodeZipDevServer(config, defaultOptions); await server.start(); - expect(mockSpawnSync).not.toHaveBeenCalledWith('npm', ['install'], expect.anything()); + expect(mockRunSubprocessCapture).not.toHaveBeenCalled(); }); it('MCP: extracts file from module:function entrypoint', async () => { diff --git a/src/cli/operations/dev/codezip-dev-server.ts b/src/cli/operations/dev/codezip-dev-server.ts index 76047749c..10db6cc60 100644 --- a/src/cli/operations/dev/codezip-dev-server.ts +++ b/src/cli/operations/dev/codezip-dev-server.ts @@ -1,4 +1,5 @@ -import { getVenvExecutable } from '../../../lib/utils/platform'; +import { getShellArgs, getShellCommand, getVenvExecutable, isWindows } from '../../../lib/utils/platform'; +import { runSubprocessCapture } from '../../../lib/utils/subprocess'; import type { ProtocolMode } from '../../../schema'; import { A2A_PORT_ENV } from './constants'; import { DevServer, type LogLevel, type SpawnConfig } from './dev-server'; @@ -72,7 +73,7 @@ function ensurePythonVenv( * Ensures Node dependencies are installed. Runs the appropriate package manager * install if `node_modules` is missing. Detects pnpm/yarn via lockfile, else npm. */ -function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) => void): boolean { +async function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) => void): Promise { if (existsSync(join(cwd, 'node_modules'))) { return true; } @@ -88,9 +89,10 @@ function ensureNodeDeps(cwd: string, onLog: (level: LogLevel, message: string) = } onLog('system', 'Installing Node dependencies...'); - const result = spawnSync(cmd, args, { cwd, stdio: 'pipe' }); - if (result.status !== 0) { - onLog('error', `Failed to install Node dependencies: ${result.stderr?.toString() || 'unknown error'}`); + const result = await runSubprocessCapture(cmd, args, { cwd }); + if (result.code !== 0) { + const detail = result.stderr || result.stdout || `${cmd} exited with code ${String(result.code)}`; + onLog('error', `Failed to install Node dependencies: ${detail}`); return false; } onLog('system', 'Node dependencies ready'); @@ -125,12 +127,11 @@ function findOtelSitecustomizeDir(venvPath: string): string | undefined { /** Dev server for CodeZip agents. Runs uvicorn (Python) or npx tsx (Node.js) locally. */ export class CodeZipDevServer extends DevServer { - protected prepare(): Promise { - return Promise.resolve( - this.config.isPython - ? ensurePythonVenv(this.config.directory, this.options.callbacks.onLog, this.config.protocol) - : ensureNodeDeps(this.config.directory, this.options.callbacks.onLog) - ); + protected async prepare(): Promise { + if (this.config.isPython) { + return ensurePythonVenv(this.config.directory, this.options.callbacks.onLog, this.config.protocol); + } + return ensureNodeDeps(this.config.directory, this.options.callbacks.onLog); } protected getSpawnConfig(): SpawnConfig { @@ -155,6 +156,14 @@ export class CodeZipDevServer extends DevServer { if (!isPython) { // TS entrypoint is already a file path like "main.ts" — pass it straight to tsx. const entryFile = module.split(':')[0] ?? module; + if (isWindows) { + return { + cmd: getShellCommand(), + args: getShellArgs(`npx tsx watch ${entryFile}`), + cwd: directory, + env, + }; + } return { cmd: 'npx', args: ['tsx', 'watch', entryFile], diff --git a/src/cli/operations/dev/web-ui/__tests__/run-web-ui.test.ts b/src/cli/operations/dev/web-ui/__tests__/run-web-ui.test.ts new file mode 100644 index 000000000..9402d4272 --- /dev/null +++ b/src/cli/operations/dev/web-ui/__tests__/run-web-ui.test.ts @@ -0,0 +1,67 @@ +import { runWebUI } from '../run-web-ui'; +import type { WebUIOptions } from '../web-server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + loggerLog: vi.fn(), + loggerFinalize: vi.fn(), + consumerLog: vi.fn(), + serverOptions: undefined as WebUIOptions | undefined, +})); + +vi.mock('../../../../logging', () => ({ + ExecLogger: class { + log = state.loggerLog; + finalize = state.loggerFinalize; + + getRelativeLogPath() { + return '.cli/logs/dev/test.log'; + } + }, +})); + +vi.mock('../../server', () => ({ + findAvailablePort: vi.fn().mockResolvedValue(8081), +})); + +vi.mock('../../utils', () => ({ + onShutdownSignal: vi.fn(), + openBrowser: vi.fn(), +})); + +vi.mock('../web-server', () => ({ + WebUIServer: class { + constructor(options: WebUIOptions) { + state.serverOptions = options; + } + + start = vi.fn(); + stop = vi.fn(); + }, +})); + +describe('runWebUI logging', () => { + beforeEach(() => { + state.loggerLog.mockClear(); + state.loggerFinalize.mockClear(); + state.consumerLog.mockClear(); + state.serverOptions = undefined; + }); + + it('writes web UI messages to the advertised log and the display handler', async () => { + void runWebUI({ + logLabel: 'dev', + onLog: state.consumerLog, + serverOptions: { + mode: 'dev', + agents: [], + }, + }); + + await vi.waitFor(() => expect(state.serverOptions).toBeDefined()); + state.serverOptions!.onLog!('error', 'Failed to install Node dependencies: spawn npm ENOENT'); + + expect(state.loggerLog).toHaveBeenCalledWith('Failed to install Node dependencies: spawn npm ENOENT', 'error'); + expect(state.consumerLog).toHaveBeenCalledWith('error', 'Failed to install Node dependencies: spawn npm ENOENT'); + }); +}); diff --git a/src/cli/operations/dev/web-ui/run-web-ui.ts b/src/cli/operations/dev/web-ui/run-web-ui.ts index 06e657c53..9a5f5537f 100644 --- a/src/cli/operations/dev/web-ui/run-web-ui.ts +++ b/src/cli/operations/dev/web-ui/run-web-ui.ts @@ -29,11 +29,15 @@ export async function runWebUI(opts: RunWebUIOptions): Promise { console.log(`Starting web UI...`); console.log(`Log: ${logger.getRelativeLogPath()}`); - const onLog = + const displayLog = opts.onLog ?? ((level: 'info' | 'warn' | 'error', msg: string) => { if (level === 'error') console.error(`Web UI: ${msg}`); }); + const onLog = (level: 'info' | 'warn' | 'error', msg: string) => { + logger.log(msg, level); + displayLog(level, msg); + }; const webUI = new WebUIServer({ ...serverOptions, @@ -52,6 +56,7 @@ export async function runWebUI(opts: RunWebUIOptions): Promise { onShutdownSignal(() => { console.log('\nStopping servers...'); webUI.stop(); + logger.finalize(true); }); // Keep process alive diff --git a/src/lib/utils/__tests__/subprocess.test.ts b/src/lib/utils/__tests__/subprocess.test.ts index 4b7dc84da..0326fe7f8 100644 --- a/src/lib/utils/__tests__/subprocess.test.ts +++ b/src/lib/utils/__tests__/subprocess.test.ts @@ -55,6 +55,7 @@ describe('runSubprocessCapture', () => { it('returns code -1 for unknown command', async () => { const result = await runSubprocessCapture('__nonexistent_command_xyz__', []); expect(result.code).toBe(-1); + expect(result.stderr).toContain('__nonexistent_command_xyz__'); }); it('respects cwd option', async () => { diff --git a/src/lib/utils/subprocess.ts b/src/lib/utils/subprocess.ts index a95f8de0f..6f19da833 100644 --- a/src/lib/utils/subprocess.ts +++ b/src/lib/utils/subprocess.ts @@ -113,8 +113,8 @@ export async function runSubprocessCapture( resolve({ stdout, stderr, code, signal }); }); - child.on('error', () => { - resolve({ stdout, stderr, code: -1, signal: null }); + child.on('error', error => { + resolve({ stdout, stderr: stderr || error.message, code: -1, signal: null }); }); }); }