fix(frontend): use spawn() instead of exec() for Windows terminal launching (#1498)

* fix(frontend): use spawn() instead of exec() for Windows terminal launching

Fixes ACS-401 - "Open Terminal & Update" button on Windows opens a
terminal but doesn't execute the update command.

Root cause: The `start` command is a CMD built-in that requires an
interactive shell context. When called via exec() which spawns its
own shell, the context is lost and start fails silently.

Changes:
- Replace exec() with spawn() for direct executable invocation
- Change from string-based command to array-based arguments
- Add proper error handling with race condition fix (resolved flag)
- Remove unused `exec` import
- Add SPAWN_WAIT_MS constant for 300ms timeout

The new spawnWindowsTerminal() function:
- Launches executables directly without requiring CMD shell
- Uses detached: true for persistent terminal windows
- Handles spawn errors via child.on('error') event
- Prevents race condition between resolve and reject paths

Ref: ACS-401

* refactor(frontend): simplify promise handling in spawnWindowsTerminal

Simplify the race condition handling by using clearTimeout and
removeListener instead of a manual resolved flag.

This is cleaner and more explicit:
- clearTimeout(timer) prevents the timeout from firing after an error
- removeListener() ensures proper cleanup of the error handler
- No manual flag needed, making the code easier to maintain

Suggested-by: gemini-code-assist bot

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-26 13:47:43 +02:00
committed by GitHub
parent 05cf0a5163
commit 26c9083d3b
@@ -8,7 +8,7 @@
*/
import { ipcMain } from 'electron';
import { exec, execFileSync, spawn, execFile } from 'child_process';
import { execFileSync, spawn, execFile } from 'child_process';
import { existsSync, readFileSync, promises as fsPromises } from 'fs';
import { mkdir, rename, unlink } from 'fs/promises';
import path from 'path';
@@ -557,21 +557,41 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
console.warn('[Claude Code] Using terminal:', terminalId);
console.warn('[Claude Code] Command to run:', command);
// For Windows, use exec with a properly formed command string
// This is more reliable than spawn for complex PowerShell commands with pipes
const runWindowsCommand = (cmdString: string): Promise<void> => {
return new Promise((resolve) => {
console.warn(`[Claude Code] Executing: ${cmdString}`);
// Fire and forget - don't wait for the terminal to close
// The -NoExit flag keeps the terminal open, so we can't wait for exec to complete
const child = exec(cmdString, { windowsHide: false });
// Helper to spawn a detached process on Windows
// This is more reliable than exec() + start command because:
// 1. spawn() launches the executable directly without requiring CMD shell
// 2. detached: true allows the terminal to persist after our app exits
// 3. stdio: 'ignore' prevents our app from being blocked by terminal output
const spawnWindowsTerminal = (executable: string, args: string[]): Promise<void> => {
// Give the process a brief moment to spawn and open its window.
// 300ms is sufficient for most terminal emulators to start, but short
// enough that the UI doesn't feel sluggish.
const SPAWN_WAIT_MS = 300;
// Detach from the child process so we don't wait for it
child.unref?.();
return new Promise((resolve, reject) => {
console.warn(`[Claude Code] Spawning: ${executable}`, args);
// Resolve immediately after starting the process
// Give it a brief moment to ensure the window opens
setTimeout(() => resolve(), 300);
const child = spawn(executable, args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
});
// Detach from the child process so it runs independently
child.unref();
const timer = setTimeout(() => {
child.removeListener('error', onError);
resolve();
}, SPAWN_WAIT_MS);
const onError = (err: Error) => {
console.error(`[Claude Code] Spawn error for ${executable}:`, err);
clearTimeout(timer);
reject(err);
};
child.on('error', onError);
});
};
@@ -581,7 +601,8 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
if (terminalId === 'windowsterminal') {
// Windows Terminal - open new tab with PowerShell
await runWindowsCommand(`wt new-tab powershell -NoExit -Command "${escapedCommand}"`);
// wt.exe accepts arguments directly without needing CMD's start command
await spawnWindowsTerminal('wt', ['new-tab', 'powershell', '-NoExit', '-Command', escapedCommand]);
} else if (terminalId === 'gitbash') {
// Git Bash - use the passed command (escaped for bash context)
const escapedBashCommand = escapeGitBashCommand(command);
@@ -591,22 +612,21 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
];
const gitBashPath = gitBashPaths.find(p => existsSync(p));
if (gitBashPath) {
await runWindowsCommand(`"${gitBashPath}" -c "${escapedBashCommand}"`);
await spawnWindowsTerminal(gitBashPath, ['-c', escapedBashCommand]);
} else {
throw new Error('Git Bash not found');
}
} else if (terminalId === 'alacritty') {
// Alacritty
await runWindowsCommand(`start alacritty -e powershell -NoExit -Command "${escapedCommand}"`);
// Alacritty - launch with PowerShell
await spawnWindowsTerminal('alacritty', ['-e', 'powershell', '-NoExit', '-Command', escapedCommand]);
} else if (terminalId === 'wezterm') {
// WezTerm
await runWindowsCommand(`start wezterm start -- powershell -NoExit -Command "${escapedCommand}"`);
// WezTerm - use start subcommand with PowerShell
await spawnWindowsTerminal('wezterm', ['start', '--', 'powershell', '-NoExit', '-Command', escapedCommand]);
} else if (terminalId === 'cmd') {
// Command Prompt - use cmd /k to run command and keep window open
// Note: cmd.exe uses its own escaping rules, so we pass the raw command
// and let cmd handle it. The command is typically PowerShell-formatted
// for install scripts, so we run PowerShell from cmd.
await runWindowsCommand(`start cmd /k "powershell -NoExit -Command ${escapedCommand}"`);
// Command Prompt - spawn cmd.exe with /k (keep window open)
// The command runs PowerShell to execute the install script
const cmdPath = process.env.ComSpec || path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'cmd.exe');
await spawnWindowsTerminal(cmdPath, ['/k', 'powershell', '-NoExit', '-Command', escapedCommand]);
} else if (terminalId === 'conemu') {
// ConEmu - open with PowerShell tab running the command
const conemuPaths = [
@@ -616,11 +636,11 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
const conemuPath = conemuPaths.find(p => existsSync(p));
if (conemuPath) {
// ConEmu uses -run to specify the command to execute
await runWindowsCommand(`start "" "${conemuPath}" -run "powershell -NoExit -Command ${escapedCommand}"`);
await spawnWindowsTerminal(conemuPath, ['-run', `powershell -NoExit -Command ${escapedCommand}`]);
} else {
// Fall back to PowerShell if ConEmu not found
console.warn('[Claude Code] ConEmu not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} else if (terminalId === 'cmder') {
// Cmder - portable console emulator for Windows
@@ -632,11 +652,11 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
const cmderPath = cmderPaths.find(p => existsSync(p));
if (cmderPath) {
// Cmder uses /TASK for predefined tasks or /START for directory, but we can use /C for command
await runWindowsCommand(`start "" "${cmderPath}" /SINGLE /START "" /TASK "powershell -NoExit -Command ${escapedCommand}"`);
await spawnWindowsTerminal(cmderPath, ['/SINGLE', '/START', '', '/TASK', `powershell -NoExit -Command ${escapedCommand}`]);
} else {
// Fall back to PowerShell if Cmder not found
console.warn('[Claude Code] Cmder not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} else if (terminalId === 'hyper') {
// Hyper - Electron-based terminal
@@ -648,11 +668,11 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
if (hyperPath) {
// Launch Hyper and it will pick up the shell; send command via PowerShell since Hyper
// doesn't have a built-in way to run commands on startup
await runWindowsCommand(`start "" "${hyperPath}"`);
await spawnWindowsTerminal(hyperPath, []);
console.warn('[Claude Code] Hyper opened - command must be pasted manually');
} else {
console.warn('[Claude Code] Hyper not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} else if (terminalId === 'tabby') {
// Tabby (formerly Terminus) - modern terminal for Windows
@@ -663,11 +683,11 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
const tabbyPath = tabbyPaths.find(p => existsSync(p));
if (tabbyPath) {
// Tabby opens with default shell; similar to Hyper, no command line arg for running commands
await runWindowsCommand(`start "" "${tabbyPath}"`);
await spawnWindowsTerminal(tabbyPath, []);
console.warn('[Claude Code] Tabby opened - command must be pasted manually');
} else {
console.warn('[Claude Code] Tabby not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} else if (terminalId === 'cygwin') {
// Cygwin terminal
@@ -679,10 +699,10 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
if (cygwinPath) {
// mintty with bash, escaping for bash context
const escapedBashCommand = escapeGitBashCommand(command);
await runWindowsCommand(`"${cygwinPath}" -e /bin/bash -lc "${escapedBashCommand}"`);
await spawnWindowsTerminal(cygwinPath, ['-e', '/bin/bash', '-lc', escapedBashCommand]);
} else {
console.warn('[Claude Code] Cygwin not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} else if (terminalId === 'msys2') {
// MSYS2 terminal
@@ -696,20 +716,19 @@ export async function openTerminalWithCommand(command: string): Promise<void> {
const escapedBashCommand = escapeGitBashCommand(command);
if (msys2Path.endsWith('.cmd')) {
// Use the shell launcher script
await runWindowsCommand(`"${msys2Path}" -mingw64 -c "${escapedBashCommand}"`);
await spawnWindowsTerminal(msys2Path, ['-mingw64', '-c', escapedBashCommand]);
} else {
// Use mintty directly
await runWindowsCommand(`"${msys2Path}" -e /bin/bash -lc "${escapedBashCommand}"`);
await spawnWindowsTerminal(msys2Path, ['-e', '/bin/bash', '-lc', escapedBashCommand]);
}
} else {
console.warn('[Claude Code] MSYS2 not found, falling back to PowerShell');
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} else {
// Default: PowerShell (handles 'powershell', 'system', or any unknown value)
// Use 'start' command to open a new PowerShell window
// The command is wrapped in double quotes and passed via -Command
await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`);
// Spawn PowerShell directly with the command - this is more reliable than using CMD's start command
await spawnWindowsTerminal('powershell', ['-NoExit', '-Command', escapedCommand]);
}
} catch (err) {
console.error('[Claude Code] Terminal execution failed:', err);