Files
orca/src/shared/windows-batch-spawn.ts
T
Jinjing 786d7048a1 fix(win32): suppress Command Prompt window on IDE launches (#11907)
* fix(win32): suppress Command Prompt window on IDE launches

- Prefer JetBrains GUI executables (`*64.exe`) over `.cmd` shims to avoid
  console allocation (STA-3040).
- Use `start "" /B` when launching GUI apps via batch scripts; shims chain
  through console helpers that allocate a visible prompt even with
  `windowsHide`. `start /B` returns immediately, preventing the lingering window.

* fix(win32): suppress Command Prompt window on IDE launches

Prevent lingering Command Prompt windows when launching JetBrains IDEs
on Windows. Use `start "" /B cmd /d /c` so the nested shell exits with
the batch script, but only for JetBrains shims—VS Code and Cursor keep
the waiting form because `start` re-parses arguments and breaks remote
paths with spaces. Prefer colocated `*64.exe` executables beside the
resolved `.cmd` shim over PATH lookups to avoid stale installations.

* fix(win32): extend IDE launcher console suppression to direct paths

Support IDE paths stored directly in settings (e.g., idea.exe,
webstorm.cmd). Detect console idea.exe stubs alongside batch shims
for upgrade to GUI *64.exe. Fix start command title escaping: use
empty string instead of '""' to prevent libuv re-quoting.
2026-08-01 13:38:45 -07:00

90 lines
3.7 KiB
TypeScript

import { win32 } from 'node:path'
/** Full path to cmd.exe for GUI and service-launched processes. */
export function getCmdExePath(): string {
return (
process.env.ComSpec ||
win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
)
}
export function isWindowsBatchScript(commandPath: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
}
export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
export class UnsafeWindowsBatchArgumentsError extends Error {
constructor() {
super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
this.name = 'UnsafeWindowsBatchArgumentsError'
}
}
// Why: cmd.exe re-parses the command line, and these are the characters that can
// start a new command or expand a variable out of an otherwise inert argument.
// `(`/`)` are deliberately absent: they only group commands, and grouping cannot
// chain anything without one of the separators below, so rejecting them merely
// broke every `C:\Program Files (x86)\...` shim and paren-bearing worktree path.
const WINDOWS_BATCH_UNSAFE_CHARACTERS = ['&', '|', '<', '>', '^', '"', '%', '!'] as const
/** The rejected characters, spelled for error messages so they cannot drift from the guard. */
export const WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL = WINDOWS_BATCH_UNSAFE_CHARACTERS.join(' ')
const UNSAFE_WINDOWS_BATCH_SYNTAX = new RegExp(
`[${WINDOWS_BATCH_UNSAFE_CHARACTERS.map((character) => character.replace(/[\\^\]-]/, '\\$&')).join('')}\\r\\n]`
)
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return UNSAFE_WINDOWS_BATCH_SYNTAX.test(value)
}
export type GetSpawnArgsForWindowsOptions = {
/**
* GUI launchers (Open In apps) should not leave a lingering Command Prompt.
* `start "" /B` returns immediately and keeps console-subsystem children of
* `.cmd`/`.bat` shims from allocating a fresh visible prompt window.
*
* Opt-in only: `start` re-parses the command line, so callers whose argv can
* carry quoted operands (VS Code `--remote` authorities and remote paths with
* spaces) must leave this off.
*/
detachedGui?: boolean
}
export function getSpawnArgsForWindows(
command: string,
args: string[],
options: GetSpawnArgsForWindowsOptions = {}
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
for (const value of [command, ...args]) {
if (hasUnsafeWindowsBatchSyntax(value)) {
throw new UnsafeWindowsBatchArgumentsError()
}
}
// Why: separate argv entries let Node quote spaces without breaking cmd.
if (options.detachedGui) {
// Why: `start` launches a batch target through a nested `cmd /K`, which
// stays resident after the script ends — `/B` only suppresses a *new*
// console, so the shim leaks a hidden cmd.exe. Handing `start` an inner
// `cmd /d /c` makes that interpreter exit with the script.
//
// Window title must be an *empty argv entry* (`''`). libuv's Windows
// quoter turns empty into `""` on the CreateProcess command line — the
// empty title `start` requires so a later quoted path is not eaten as
// the title. The two-character string `'""'` is wrong: libuv re-escapes
// it to `"\"\""`. (Default ComSpec has no spaces, so the bad form often
// still "works"; quoted Program Files paths are where it breaks.)
const cmdExePath = getCmdExePath()
return {
spawnCmd: cmdExePath,
spawnArgs: ['/d', '/c', 'start', '', '/B', cmdExePath, '/d', '/c', command, ...args]
}
}
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }
}