diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs index 159415ef..f3f56971 100644 --- a/scripts/dev-web.mjs +++ b/scripts/dev-web.mjs @@ -2,9 +2,10 @@ import path from 'node:path'; import process from 'node:process'; -import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { spawnWithWindowsShell } from './lib/windows-shell-spawn.mjs'; + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); const standalonePort = process.env.STANDALONE_PORT?.trim() || '3456'; @@ -13,22 +14,11 @@ const corsOrigin = process.env.CORS_ORIGIN?.trim() || `http://127.0.0.1:${webPort},http://localhost:${webPort}`; -const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); - -function shouldUseWindowsShell(cmd) { - if (process.platform !== 'win32') { - return false; - } - - return WINDOWS_SHELL_COMMANDS.has(path.basename(cmd).toLowerCase()); -} - function spawnProcess(cmd, args, env) { - return spawn(cmd, args, { + return spawnWithWindowsShell(cmd, args, { cwd: repoRoot, env: { ...process.env, ...env }, stdio: 'inherit', - shell: shouldUseWindowsShell(cmd), }); } diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index 1ac19992..c20f751c 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -4,11 +4,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; -import { spawnSync } from 'node:child_process'; import { once } from 'node:events'; import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const uiRepoRoot = path.resolve(scriptDir, '..'); const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() ?? ''; @@ -22,26 +23,10 @@ const scriptArgs = process.argv.slice(2); const shouldPrintRuntimePath = scriptArgs.includes('--print-runtime-path'); const electronViteArgs = scriptArgs.filter((arg) => arg !== '--print-runtime-path' && arg !== '--'); const runtimeDisplayName = 'teams orchestrator'; -const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); - -function shouldUseWindowsShell(cmd) { - if (process.platform !== 'win32') { - return false; - } - - const extension = path.extname(cmd).toLowerCase(); - if (extension === '.cmd' || extension === '.bat') { - return true; - } - - const commandName = path.basename(cmd).toLowerCase(); - return WINDOWS_SHELL_COMMANDS.has(commandName); -} function runOrExit(cmd, args, options = {}) { - const result = spawnSync(cmd, args, { + const result = spawnSyncWithWindowsShell(cmd, args, { stdio: 'inherit', - shell: shouldUseWindowsShell(cmd), ...options, }); @@ -56,9 +41,8 @@ function runOrExit(cmd, args, options = {}) { } function runAndCapture(cmd, args, options = {}) { - const result = spawnSync(cmd, args, { + const result = spawnSyncWithWindowsShell(cmd, args, { encoding: 'utf8', - shell: shouldUseWindowsShell(cmd), ...options, }); diff --git a/scripts/lib/windows-shell-spawn.mjs b/scripts/lib/windows-shell-spawn.mjs new file mode 100644 index 00000000..002e6fef --- /dev/null +++ b/scripts/lib/windows-shell-spawn.mjs @@ -0,0 +1,59 @@ +import { spawn, spawnSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; + +const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); + +export function quoteWindowsCmdArg(value) { + const text = String(value); + if (text.length === 0) { + return '""'; + } + if (!/[ \t\r\n"&|<>^()%!]/.test(text)) { + return text; + } + return `"${text.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`; +} + +export function shouldUseWindowsShell(command) { + if (process.platform !== 'win32') { + return false; + } + + const extension = path.extname(command).toLowerCase(); + if (extension === '.cmd' || extension === '.bat') { + return true; + } + + return WINDOWS_SHELL_COMMANDS.has(path.basename(command).toLowerCase()); +} + +function toWindowsShellCommand(command, args) { + return [command, ...args].map(quoteWindowsCmdArg).join(' '); +} + +export function spawnWithWindowsShell(command, args, options = {}) { + if (!shouldUseWindowsShell(command)) { + return spawn(command, args, options); + } + + const safeOptions = { ...options }; + delete safeOptions.shell; + return spawn(toWindowsShellCommand(command, args), { + ...safeOptions, + shell: true, + }); +} + +export function spawnSyncWithWindowsShell(command, args, options = {}) { + if (!shouldUseWindowsShell(command)) { + return spawnSync(command, args, options); + } + + const safeOptions = { ...options }; + delete safeOptions.shell; + return spawnSync(toWindowsShellCommand(command, args), { + ...safeOptions, + shell: true, + }); +} diff --git a/scripts/prove-agent-cli-launch.mjs b/scripts/prove-agent-cli-launch.mjs index eaf63af5..980f0169 100644 --- a/scripts/prove-agent-cli-launch.mjs +++ b/scripts/prove-agent-cli-launch.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { resolveLiveSmokeOrchestratorCliPath } from './lib/live-smoke-runtime.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -26,7 +26,7 @@ if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) { console.log('Running agent CLI launch live smoke'); console.log(`Claude runtime: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -42,7 +42,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/scripts/prove-opencode-mixed-recovery.mjs b/scripts/prove-opencode-mixed-recovery.mjs index 3a896a7f..59845aa2 100644 --- a/scripts/prove-opencode-mixed-recovery.mjs +++ b/scripts/prove-opencode-mixed-recovery.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -10,6 +9,7 @@ import { exitForSkippedPreflight, preflightOpenCodeLiveEnvironment, } from './lib/opencode-live-preflight.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -40,7 +40,7 @@ console.log(`Multi-lane: ${env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? 'enab const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); exitForSkippedPreflight(preflight); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -56,7 +56,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/scripts/prove-opencode-semantic-gauntlet.mjs b/scripts/prove-opencode-semantic-gauntlet.mjs index 031b18a1..8366d98b 100644 --- a/scripts/prove-opencode-semantic-gauntlet.mjs +++ b/scripts/prove-opencode-semantic-gauntlet.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -10,6 +9,7 @@ import { exitForSkippedPreflight, preflightOpenCodeLiveEnvironment, } from './lib/opencode-live-preflight.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -46,7 +46,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`) const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); exitForSkippedPreflight(preflight); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -62,7 +62,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/scripts/prove-opencode-semantic-messaging.mjs b/scripts/prove-opencode-semantic-messaging.mjs index 1eee1cb0..a62877a8 100644 --- a/scripts/prove-opencode-semantic-messaging.mjs +++ b/scripts/prove-opencode-semantic-messaging.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -10,6 +9,7 @@ import { exitForSkippedPreflight, preflightOpenCodeLiveEnvironment, } from './lib/opencode-live-preflight.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -38,7 +38,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`) const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); exitForSkippedPreflight(preflight); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -54,7 +54,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/scripts/prove-opencode-semantic-model-matrix.mjs b/scripts/prove-opencode-semantic-model-matrix.mjs index 9f91862f..1b74e5c6 100644 --- a/scripts/prove-opencode-semantic-model-matrix.mjs +++ b/scripts/prove-opencode-semantic-model-matrix.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -10,6 +9,7 @@ import { exitForSkippedPreflight, preflightOpenCodeLiveEnvironment, } from './lib/opencode-live-preflight.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -36,7 +36,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`) const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); exitForSkippedPreflight(preflight); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -52,7 +52,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/scripts/prove-opencode-team-provisioning.mjs b/scripts/prove-opencode-team-provisioning.mjs index 12673fd3..5cca86a3 100644 --- a/scripts/prove-opencode-team-provisioning.mjs +++ b/scripts/prove-opencode-team-provisioning.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -10,6 +9,7 @@ import { exitForSkippedPreflight, preflightOpenCodeLiveEnvironment, } from './lib/opencode-live-preflight.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -38,7 +38,7 @@ console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`) const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); exitForSkippedPreflight(preflight); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -54,7 +54,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/scripts/prove-provider-launch-stress.mjs b/scripts/prove-provider-launch-stress.mjs index eaea932b..79665188 100644 --- a/scripts/prove-provider-launch-stress.mjs +++ b/scripts/prove-provider-launch-stress.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { resolveLiveSmokeOrchestratorCliPath } from './lib/live-smoke-runtime.mjs'; import { preflightOpenCodeLiveEnvironment } from './lib/opencode-live-preflight.mjs'; +import { spawnSyncWithWindowsShell } from './lib/windows-shell-spawn.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); @@ -69,7 +70,7 @@ if (preflight.skipped.length > 0 && process.env.PROVIDER_LAUNCH_STRESS_STRICT == env.PROVIDER_LAUNCH_STRESS_ORDER = preflight.order.join(','); console.log(`Runnable order: ${env.PROVIDER_LAUNCH_STRESS_ORDER}`); -const result = spawnSync( +const result = spawnSyncWithWindowsShell( 'pnpm', [ 'exec', @@ -85,7 +86,6 @@ const result = spawnSync( cwd: repoRoot, env, stdio: 'inherit', - shell: process.platform === 'win32', } ); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 3b19dedc..2cfdbd9d 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -312,10 +312,25 @@ function spawnRealCli( ) { const spawnOptions = options ?? {}; const needsWindowsCommandShell = process.platform === 'win32' && /\.(bat|cmd)$/i.test(command); - return spawn(command, [...args], { - ...spawnOptions, - ...(needsWindowsCommandShell ? { shell: true } : {}), - }); + if (needsWindowsCommandShell) { + const commandLine = [command, ...args].map(quoteWindowsCmdArg).join(' '); + return spawn(commandLine, { + ...spawnOptions, + shell: true, + }); + } + + return spawn(command, [...args], spawnOptions); +} + +function quoteWindowsCmdArg(value: string) { + if (value.length === 0) { + return '""'; + } + if (!/[ \t\r\n"&|<>^()%!]/.test(value)) { + return value; + } + return `"${value.replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`; } async function removeTempRoot(dirPath: string): Promise {