diff --git a/docs/system-prompt.md b/docs/system-prompt.md index fb5bb1305..3b7de1ba0 100644 --- a/docs/system-prompt.md +++ b/docs/system-prompt.md @@ -62,5 +62,4 @@ You are in a git worktree at ${workspacePath} } ``` - {/* END SYSTEM_PROMPT_DOCS */} diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index a4a57d10e..9f966ab3f 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -18,10 +18,11 @@ import type { } from "./Runtime"; import { RuntimeError as RuntimeErrorClass } from "./Runtime"; import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; -import { getBashPath } from "@/node/utils/main/bashPath"; +import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath"; import { EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT } from "@/common/constants/exitCodes"; import { DisposableProcess } from "@/node/utils/disposableExec"; import { expandTilde } from "./tildeExpansion"; +import { log } from "@/node/services/log"; import { checkInitHookExists, getInitHookPath, @@ -67,18 +68,39 @@ export abstract class LocalBaseRuntime implements Runtime { ); } + // Get spawn config for the preferred bash runtime + // This handles Git for Windows, WSL, and Unix/macOS automatically + // For WSL, paths in the command and cwd are translated to /mnt/... format + const { + command: bashCommand, + args: bashArgs, + cwd: spawnCwd, + } = getPreferredSpawnConfig(command, cwd); + + // Debug logging for Windows WSL issues (skip noisy git status commands) + const isGitStatusCmd = command.includes("git status") || command.includes("show-branch") || command.includes("PRIMARY_BRANCH"); + if (!isGitStatusCmd) { + log.info(`[LocalBaseRuntime.exec] Original command: ${command.substring(0, 100)}${command.length > 100 ? "..." : ""}`); + log.info(`[LocalBaseRuntime.exec] Spawn command: ${bashCommand}`); + log.info(`[LocalBaseRuntime.exec] Spawn args: ${JSON.stringify(bashArgs).substring(0, 200)}...`); + } + // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues // Windows doesn't have nice command, so just spawn bash directly const isWindows = process.platform === "win32"; - const bashPath = getBashPath(); - const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashPath; + const spawnCommand = options.niceness !== undefined && !isWindows ? "nice" : bashCommand; const spawnArgs = options.niceness !== undefined && !isWindows - ? ["-n", options.niceness.toString(), bashPath, "-c", command] - : ["-c", command]; + ? ["-n", options.niceness.toString(), bashCommand, ...bashArgs] + : bashArgs; + + // On Windows with PowerShell wrapper, detached:true creates a separate console + // which interferes with output capture. Only use detached on non-Windows. + // On Windows, PowerShell's -WindowStyle Hidden handles console hiding. + const useDetached = !isWindows; const childProcess = spawn(spawnCommand, spawnArgs, { - cwd, + cwd: spawnCwd, env: { ...process.env, ...(options.env ?? {}), @@ -90,7 +112,8 @@ export abstract class LocalBaseRuntime implements Runtime { // the entire process group (including all backgrounded children) via process.kill(-pid). // NOTE: detached:true does NOT cause bash to wait for background jobs when using 'exit' event // instead of 'close' event. The 'exit' event fires when bash exits, ignoring background children. - detached: true, + // WINDOWS NOTE: detached:true causes issues with PowerShell wrapper output capture. + detached: useDetached, // Prevent console window from appearing on Windows (WSL bash spawns steal focus otherwise) windowsHide: true, }); @@ -110,6 +133,18 @@ export abstract class LocalBaseRuntime implements Runtime { let timedOut = false; let aborted = false; + // Debug: log raw stdout/stderr from the child process (only for non-git-status commands) + let debugStdout = ""; + let debugStderr = ""; + if (!isGitStatusCmd) { + childProcess.stdout?.on("data", (chunk: Buffer) => { + debugStdout += chunk.toString(); + }); + childProcess.stderr?.on("data", (chunk: Buffer) => { + debugStderr += chunk.toString(); + }); + } + // Create promises for exit code and duration // Uses special exit codes (EXIT_CODE_ABORTED, EXIT_CODE_TIMEOUT) for expected error conditions const exitCode = new Promise((resolve, reject) => { @@ -118,6 +153,14 @@ export abstract class LocalBaseRuntime implements Runtime { // which causes hangs when users spawn background processes like servers. // The 'exit' event fires when the main bash process exits, which is what we want. childProcess.on("exit", (code) => { + if (!isGitStatusCmd) { + log.info(`[LocalBaseRuntime.exec] Process exited with code: ${code}`); + log.info(`[LocalBaseRuntime.exec] stdout length: ${debugStdout.length}`); + log.info(`[LocalBaseRuntime.exec] stdout: ${debugStdout.substring(0, 500)}${debugStdout.length > 500 ? "..." : ""}`); + if (debugStderr) { + log.info(`[LocalBaseRuntime.exec] stderr: ${debugStderr.substring(0, 500)}${debugStderr.length > 500 ? "..." : ""}`); + } + } // Clean up any background processes (process group cleanup) // This prevents zombie processes when scripts spawn background tasks if (childProcess.pid !== undefined) { @@ -367,9 +410,16 @@ export abstract class LocalBaseRuntime implements Runtime { const loggers = createLineBufferedLoggers(initLogger); return new Promise((resolve) => { - const bashPath = getBashPath(); - const proc = spawn(bashPath, ["-c", `"${hookPath}"`], { - cwd: workspacePath, + // Get spawn config for the preferred bash runtime + // For WSL, the hook path and cwd are translated to /mnt/... format + const { + command: bashCommand, + args: bashArgs, + cwd: spawnCwd, + } = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath); + + const proc = spawn(bashCommand, bashArgs, { + cwd: spawnCwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, @@ -379,11 +429,11 @@ export abstract class LocalBaseRuntime implements Runtime { windowsHide: true, }); - proc.stdout.on("data", (data: Buffer) => { + proc.stdout?.on("data", (data: Buffer) => { loggers.stdout.append(data.toString()); }); - proc.stderr.on("data", (data: Buffer) => { + proc.stderr?.on("data", (data: Buffer) => { loggers.stderr.append(data.toString()); }); diff --git a/src/node/services/bashExecutionService.ts b/src/node/services/bashExecutionService.ts index 62199a7f6..17173fb4d 100644 --- a/src/node/services/bashExecutionService.ts +++ b/src/node/services/bashExecutionService.ts @@ -1,7 +1,7 @@ import { spawn } from "child_process"; import type { ChildProcess } from "child_process"; import { log } from "./log"; -import { getBashPath } from "@/node/utils/main/bashPath"; +import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath"; /** * Configuration for bash execution @@ -121,17 +121,25 @@ export class BashExecutionService { `BashExecutionService: Script: ${script.substring(0, 100)}${script.length > 100 ? "..." : ""}` ); + // Get spawn config for the preferred bash runtime + // This handles Git for Windows, WSL, and Unix/macOS automatically + // For WSL, paths in the script and cwd are translated to /mnt/... format + const { + command: bashCommand, + args: bashArgs, + cwd: spawnCwd, + } = getPreferredSpawnConfig(script, config.cwd); + // Windows doesn't have nice command, so just spawn bash directly const isWindows = process.platform === "win32"; - const bashPath = getBashPath(); - const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashPath; + const spawnCommand = config.niceness !== undefined && !isWindows ? "nice" : bashCommand; const spawnArgs = config.niceness !== undefined && !isWindows - ? ["-n", config.niceness.toString(), bashPath, "-c", script] - : ["-c", script]; + ? ["-n", config.niceness.toString(), bashCommand, ...bashArgs] + : bashArgs; const child = spawn(spawnCommand, spawnArgs, { - cwd: config.cwd, + cwd: spawnCwd, env: this.createBashEnvironment(config.secrets), stdio: ["ignore", "pipe", "pipe"], // Spawn as detached process group leader to prevent zombie processes diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index 0581c32a4..d732e17c1 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -1,5 +1,7 @@ -import { exec } from "child_process"; +import { spawn } from "child_process"; import type { ChildProcess } from "child_process"; +import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath"; +import { log } from "@/node/services/log"; /** * Disposable wrapper for child processes that ensures immediate cleanup. @@ -117,12 +119,32 @@ class DisposableExec implements Disposable { * Execute command with automatic cleanup via `using` declaration. * Prevents zombie processes by ensuring child is reaped even on error. * + * Commands are always wrapped in `bash -c` for consistent behavior across platforms. + * On Windows, this uses the detected bash runtime (Git for Windows or WSL). + * For WSL, Windows paths in the command are automatically translated. + * * @example * using proc = execAsync("git status"); * const { stdout } = await proc.result; */ export function execAsync(command: string): DisposableExec { - const child = exec(command); + // Wrap command in bash -c for consistent cross-platform behavior + // For WSL, this also translates Windows paths to /mnt/... format + const { command: bashCmd, args } = getPreferredSpawnConfig(command); + + // Debug logging for Windows WSL issues + log.info(`[execAsync] Original command: ${command}`); + log.info(`[execAsync] Spawn command: ${bashCmd}`); + log.info(`[execAsync] Spawn args: ${JSON.stringify(args)}`); + + const child = spawn(bashCmd, args, { + stdio: ["ignore", "pipe", "pipe"], + // Prevent console window from appearing on Windows + windowsHide: true, + }); + + log.info(`[execAsync] Spawned process PID: ${child.pid}`); + const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { let stdout = ""; let stderr = ""; @@ -141,9 +163,16 @@ export function execAsync(command: string): DisposableExec { child.on("exit", (code, signal) => { exitCode = code; exitSignal = signal; + log.info(`[execAsync] Process exited with code: ${code}, signal: ${signal}`); }); child.on("close", () => { + log.info(`[execAsync] Process closed. stdout length: ${stdout.length}, stderr length: ${stderr.length}`); + log.info(`[execAsync] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`); + if (stderr) { + log.info(`[execAsync] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`); + } + // Only resolve if process exited cleanly (code 0, no signal) if (exitCode === 0 && exitSignal === null) { resolve({ stdout, stderr }); diff --git a/src/node/utils/main/bashPath.test.ts b/src/node/utils/main/bashPath.test.ts new file mode 100644 index 000000000..99d096167 --- /dev/null +++ b/src/node/utils/main/bashPath.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "bun:test"; +import { + detectBashRuntimes, + getSpawnConfig, + getPreferredSpawnConfig, + isBashAvailable, + windowsToWslPath, + translateWindowsPathsInCommand, + type BashRuntime, +} from "./bashPath"; + +describe("bashPath", () => { + describe("detectBashRuntimes", () => { + it("should detect at least one runtime", () => { + const runtimes = detectBashRuntimes(); + expect(runtimes.available.length).toBeGreaterThan(0); + expect(runtimes.preferred).toBeDefined(); + }); + + it("should return unix runtime on non-Windows platforms", () => { + // This test runs on Linux/macOS CI + if (process.platform !== "win32") { + const runtimes = detectBashRuntimes(); + expect(runtimes.preferred.type).toBe("unix"); + expect(runtimes.available).toContainEqual({ type: "unix" }); + } + }); + + it("should cache results", () => { + const first = detectBashRuntimes(); + const second = detectBashRuntimes(); + expect(first).toBe(second); // Same object reference + }); + }); + + describe("getSpawnConfig", () => { + it("should generate correct config for unix runtime", () => { + const runtime: BashRuntime = { type: "unix" }; + const config = getSpawnConfig(runtime, "echo hello"); + + expect(config.command).toBe("bash"); + expect(config.args).toEqual(["-c", "echo hello"]); + }); + + it("should generate correct config for git-bash runtime", () => { + const runtime: BashRuntime = { + type: "git-bash", + bashPath: "C:\\Program Files\\Git\\bin\\bash.exe", + }; + const config = getSpawnConfig(runtime, "echo hello"); + + expect(config.command).toBe("C:\\Program Files\\Git\\bin\\bash.exe"); + expect(config.args).toEqual(["-c", "echo hello"]); + }); + + it("should generate correct config for wsl runtime with distro", () => { + const runtime: BashRuntime = { type: "wsl", distro: "Ubuntu" }; + const config = getSpawnConfig(runtime, "echo hello"); + + expect(config.command).toBe("wsl"); + expect(config.args).toContain("-d"); + expect(config.args).toContain("Ubuntu"); + expect(config.args).toContain("--"); + expect(config.args).toContain("bash"); + expect(config.args).toContain("-c"); + expect(config.args[config.args.length - 1]).toContain("echo hello"); + }); + + it("should generate correct config for wsl runtime without distro", () => { + const runtime: BashRuntime = { type: "wsl", distro: null }; + const config = getSpawnConfig(runtime, "echo hello"); + + expect(config.command).toBe("wsl"); + expect(config.args).not.toContain("-d"); + expect(config.args).toContain("--"); + expect(config.args).toContain("bash"); + expect(config.args[config.args.length - 1]).toContain("echo hello"); + }); + + it("should handle complex scripts with quotes and special characters", () => { + const runtime: BashRuntime = { type: "unix" }; + const script = 'git commit -m "test message" && echo "done"'; + const config = getSpawnConfig(runtime, script); + + expect(config.args[1]).toBe(script); + }); + }); + + describe("getPreferredSpawnConfig", () => { + it("should return valid spawn config", () => { + const config = getPreferredSpawnConfig("ls -la"); + + expect(config.command).toBeDefined(); + expect(config.args).toBeArray(); + expect(config.args.length).toBeGreaterThan(0); + }); + + it("should include the script in args", () => { + const script = "git status --porcelain"; + const config = getPreferredSpawnConfig(script); + + // Script should be in args (either directly or as part of -c arg) + expect(config.args.join(" ")).toContain(script); + }); + }); + + describe("isBashAvailable", () => { + it("should return true when bash is available", () => { + // On CI (Linux), bash should always be available + if (process.platform !== "win32") { + expect(isBashAvailable()).toBe(true); + } + }); + }); + + describe("windowsToWslPath", () => { + it("should convert C:\\ paths to /mnt/c/", () => { + expect(windowsToWslPath("C:\\Users\\micha\\source\\mux")).toBe( + "/mnt/c/Users/micha/source/mux" + ); + }); + + it("should convert D:\\ paths to /mnt/d/", () => { + expect(windowsToWslPath("D:\\Projects\\myapp")).toBe("/mnt/d/Projects/myapp"); + }); + + it("should handle lowercase drive letters", () => { + expect(windowsToWslPath("c:\\temp")).toBe("/mnt/c/temp"); + }); + + it("should handle forward slashes in Windows paths", () => { + expect(windowsToWslPath("C:/Users/micha")).toBe("/mnt/c/Users/micha"); + }); + + it("should return non-Windows paths unchanged", () => { + expect(windowsToWslPath("/home/user")).toBe("/home/user"); + expect(windowsToWslPath("relative/path")).toBe("relative/path"); + }); + + it("should handle paths with spaces", () => { + expect(windowsToWslPath("C:\\Program Files\\Git")).toBe("/mnt/c/Program Files/Git"); + }); + }); + + describe("translateWindowsPathsInCommand", () => { + it("should translate unquoted paths", () => { + expect(translateWindowsPathsInCommand("cd C:\\Users\\micha")).toBe("cd /mnt/c/Users/micha"); + }); + + it("should translate double-quoted paths", () => { + expect(translateWindowsPathsInCommand('git -C "C:\\Users\\micha\\mux" status')).toBe( + 'git -C "/mnt/c/Users/micha/mux" status' + ); + }); + + it("should translate single-quoted paths", () => { + expect(translateWindowsPathsInCommand("ls 'D:\\Projects'")).toBe("ls '/mnt/d/Projects'"); + }); + + it("should translate multiple paths in one command", () => { + expect(translateWindowsPathsInCommand('cp "C:\\src" "D:\\dest"')).toBe( + 'cp "/mnt/c/src" "/mnt/d/dest"' + ); + }); + + it("should leave non-Windows paths alone", () => { + const cmd = "ls /home/user && cat file.txt"; + expect(translateWindowsPathsInCommand(cmd)).toBe(cmd); + }); + + it("should handle git -C commands", () => { + expect( + translateWindowsPathsInCommand('git -C "C:\\Users\\micha\\source\\mux" worktree list') + ).toBe('git -C "/mnt/c/Users/micha/source/mux" worktree list'); + }); + + it("should handle double-quoted paths with spaces", () => { + expect(translateWindowsPathsInCommand('cd "C:\\Users\\John Doe\\My Documents"')).toBe( + 'cd "/mnt/c/Users/John Doe/My Documents"' + ); + }); + + it("should handle single-quoted paths with spaces", () => { + expect(translateWindowsPathsInCommand("cd 'D:\\Program Files\\My App'")).toBe( + "cd '/mnt/d/Program Files/My App'" + ); + }); + + it("should handle mixed quoted paths with and without spaces", () => { + expect( + translateWindowsPathsInCommand('cp "C:\\Users\\John Doe\\file.txt" C:\\dest') + ).toBe('cp "/mnt/c/Users/John Doe/file.txt" /mnt/c/dest'); + }); + }); + + describe("getSpawnConfig with WSL path translation", () => { + it("should translate cwd for WSL runtime", () => { + const runtime: BashRuntime = { type: "wsl", distro: "Ubuntu" }; + const config = getSpawnConfig(runtime, "ls", "C:\\Users\\micha"); + + // cwd is embedded in the script via 'cd' command + expect(config.cwd).toBeUndefined(); + // The translated cwd should be in the bash script + const bashScript = config.args[config.args.length - 1]; + expect(bashScript).toContain("/mnt/c/Users/micha"); + }); + + it("should translate paths in script for WSL runtime", () => { + const runtime: BashRuntime = { type: "wsl", distro: null }; + const config = getSpawnConfig(runtime, 'git -C "C:\\Projects\\app" status'); + + // The script has translated paths + const bashScript = config.args[config.args.length - 1]; + expect(bashScript).toContain("/mnt/c/Projects/app"); + }); + + it("should not translate paths for git-bash runtime", () => { + const runtime: BashRuntime = { + type: "git-bash", + bashPath: "C:\\Program Files\\Git\\bin\\bash.exe", + }; + const config = getSpawnConfig(runtime, 'git -C "C:\\Projects" status', "C:\\Projects"); + + expect(config.cwd).toBe("C:\\Projects"); + expect(config.args[1]).toBe('git -C "C:\\Projects" status'); + }); + + it("should not translate paths for unix runtime", () => { + const runtime: BashRuntime = { type: "unix" }; + const config = getSpawnConfig(runtime, "ls", "/home/user"); + + expect(config.cwd).toBe("/home/user"); + }); + }); +}); diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index 052de9e9b..ced6b633b 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -1,21 +1,140 @@ /** - * Platform-specific bash path resolution + * Platform-specific bash runtime detection and execution * * On Unix/Linux/macOS, bash is in PATH by default. - * On Windows, bash comes from Git Bash and needs to be located. + * On Windows, we detect available runtimes (Git for Windows, WSL) and use + * absolute paths to bash, always wrapping commands in `bash -c`. + * + * Priority on Windows: WSL > Git for Windows (if both available) + * WSL provides a full Linux environment with better tool compatibility. + * Windows paths are automatically translated (C:\Users\... -> /mnt/c/Users/...). + * cwd is embedded in the script via 'cd' since WSL needs Linux paths. */ import { execSync } from "child_process"; import { existsSync } from "fs"; import path from "path"; -let cachedBashPath: string | null = null; +// ============================================================================ +// Types +// ============================================================================ + +/** Git for Windows bash runtime */ +export interface GitBashRuntime { + type: "git-bash"; + /** Absolute path to bash.exe */ + bashPath: string; +} + +/** WSL (Windows Subsystem for Linux) runtime */ +export interface WslRuntime { + type: "wsl"; + /** WSL distro name, or null for default distro */ + distro: string | null; +} + +/** Unix/Linux/macOS native bash */ +export interface UnixBashRuntime { + type: "unix"; +} + +export type BashRuntime = GitBashRuntime | WslRuntime | UnixBashRuntime; + +export interface DetectedRuntimes { + /** All available runtimes on this system */ + available: BashRuntime[]; + /** The preferred runtime to use (Git for Windows > WSL on Windows) */ + preferred: BashRuntime; +} + +export interface SpawnConfig { + /** Command to spawn */ + command: string; + /** Arguments for the command */ + args: string[]; + /** Working directory (translated for WSL if needed) */ + cwd?: string; +} + +// ============================================================================ +// Path Translation +// ============================================================================ + +/** + * Convert a Windows path to a WSL path + * C:\Users\name -> /mnt/c/Users/name + * D:\Projects -> /mnt/d/Projects + */ +export function windowsToWslPath(windowsPath: string): string { + // Match drive letter paths like C:\ or C:/ + const driveMatch = /^([a-zA-Z]):[/\\](.*)$/.exec(windowsPath); + if (driveMatch) { + const driveLetter = driveMatch[1].toLowerCase(); + const rest = driveMatch[2].replace(/\\/g, "/"); + return `/mnt/${driveLetter}/${rest}`; + } + // Not a Windows path, return as-is + return windowsPath; +} + +/** + * Translate Windows paths in a command string for WSL + * Finds patterns like C:\... or "C:\..." and converts them + */ +export function translateWindowsPathsInCommand(command: string): string { + // Match Windows paths with different quote styles: + // 1. Double-quoted: "C:\Users\John Doe\repo" - can contain spaces + // 2. Single-quoted: 'C:\Users\John Doe\repo' - can contain spaces + // 3. Unquoted: C:\Users\name\repo - no spaces allowed + return command.replace( + /"([a-zA-Z]):[/\\]([^"]*)"|'([a-zA-Z]):[/\\]([^']*)'|([a-zA-Z]):[/\\]([^\s]*)/g, + ( + _match: string, + dqDrive: string | undefined, + dqRest: string | undefined, + sqDrive: string | undefined, + sqRest: string | undefined, + uqDrive: string | undefined, + uqRest: string | undefined + ) => { + if (dqDrive !== undefined) { + // Double-quoted path + const wslPath = `/mnt/${dqDrive.toLowerCase()}/${dqRest!.replace(/\\/g, "/")}`; + return `"${wslPath}"`; + } else if (sqDrive !== undefined) { + // Single-quoted path + const wslPath = `/mnt/${sqDrive.toLowerCase()}/${sqRest!.replace(/\\/g, "/")}`; + return `'${wslPath}'`; + } else { + // Unquoted path + const wslPath = `/mnt/${uqDrive!.toLowerCase()}/${uqRest!.replace(/\\/g, "/")}`; + return wslPath; + } + } + ); +} + +/** + * Translate a path for the given runtime + */ +export function translatePathForRuntime(pathStr: string, runtime: BashRuntime): string { + if (runtime.type === "wsl" && process.platform === "win32") { + return windowsToWslPath(pathStr); + } + return pathStr; +} + +// ============================================================================ +// Detection +// ============================================================================ + +let cachedRuntimes: DetectedRuntimes | null = null; /** - * Find bash executable path on Windows - * Checks common Git Bash installation locations + * Find Git for Windows bash.exe path + * Checks common installation locations */ -function findWindowsBash(): string | null { +function findGitBash(): string | null { // Common Git Bash installation paths const commonPaths = [ // Git for Windows default paths @@ -23,17 +142,18 @@ function findWindowsBash(): string | null { "C:\\Program Files (x86)\\Git\\bin\\bash.exe", // User-local Git installation path.join(process.env.LOCALAPPDATA ?? "", "Programs", "Git", "bin", "bash.exe"), - // Portable Git + // Portable Git (Scoop) path.join(process.env.USERPROFILE ?? "", "scoop", "apps", "git", "current", "bin", "bash.exe"), // Chocolatey installation "C:\\tools\\git\\bin\\bash.exe", ]; - // Check if bash is in PATH first + // Check if bash is in PATH first (might be Git Bash added to PATH) try { const result = execSync("where bash", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }); const firstPath = result.split("\n")[0].trim(); - if (firstPath && existsSync(firstPath)) { + // Only use if it's not WSL bash (which would be in System32) + if (firstPath && existsSync(firstPath) && !firstPath.toLowerCase().includes("system32")) { return firstPath; } } catch { @@ -72,33 +192,282 @@ function findWindowsBash(): string | null { } /** - * Get the bash executable path for the current platform - * - * @returns Path to bash executable. On Unix/macOS returns "bash", - * on Windows returns full path to bash.exe if found. - * @throws Error if bash cannot be found on Windows + * Detect available WSL distros + * Returns the default distro name if WSL is available */ -export function getBashPath(): string { - // On Unix/Linux/macOS, bash is in PATH +function findWslDistro(): string | null { + try { + // Check if wsl.exe exists and works + const result = execSync("wsl --list --quiet", { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + // WSL outputs distro names, first one is typically the default + // Output may have UTF-16 BOM and null bytes, clean it up + const distros = result + .replace(/\0/g, "") // Remove null bytes from UTF-16 + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (distros.length > 0) { + return distros[0]; // Return default/first distro + } + } catch { + // WSL not available or no distros installed + } + + return null; +} + +/** + * Find PowerShell executable path + * We need the full path because Node.js spawn() may not have the same PATH as a user shell + */ +function findPowerShell(): string | null { + // PowerShell Core (pwsh) locations - preferred as it's cross-platform + const pwshPaths = [ + // PowerShell Core default installation + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "C:\\Program Files\\PowerShell\\6\\pwsh.exe", + // User-local installation + path.join(process.env.LOCALAPPDATA ?? "", "Microsoft", "PowerShell", "pwsh.exe"), + ]; + + // Windows PowerShell (powershell.exe) - always present on Windows + const windowsPowerShellPaths = [ + // 64-bit PowerShell + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + // 32-bit PowerShell (on 64-bit systems via SysWOW64) + "C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe", + ]; + + // Try PowerShell Core first (better performance) + for (const psPath of pwshPaths) { + if (existsSync(psPath)) { + return psPath; + } + } + + // Try to find pwsh in PATH + try { + const result = execSync("where pwsh", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }); + const firstPath = result.split("\n")[0].trim(); + if (firstPath && existsSync(firstPath)) { + return firstPath; + } + } catch { + // pwsh not in PATH + } + + // Fall back to Windows PowerShell + for (const psPath of windowsPowerShellPaths) { + if (existsSync(psPath)) { + return psPath; + } + } + + // Last resort: try to find powershell in PATH + try { + const result = execSync("where powershell", { + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + }); + const firstPath = result.split("\n")[0].trim(); + if (firstPath && existsSync(firstPath)) { + return firstPath; + } + } catch { + // powershell not in PATH + } + + return null; +} + +// Cached PowerShell path (set during runtime detection) +let cachedPowerShellPath: string | null | undefined = undefined; + +/** + * Get the PowerShell path, detecting it if not yet cached + */ +function getPowerShellPath(): string | null { + if (cachedPowerShellPath === undefined) { + cachedPowerShellPath = findPowerShell(); + } + return cachedPowerShellPath; +} + +/** + * Detect all available bash runtimes on the current system + * Results are cached for performance + */ +export function detectBashRuntimes(): DetectedRuntimes { + // Return cached result if available + if (cachedRuntimes !== null) { + return cachedRuntimes; + } + + // On Unix/Linux/macOS, just use native bash if (process.platform !== "win32") { - return "bash"; + cachedRuntimes = { + available: [{ type: "unix" }], + preferred: { type: "unix" }, + }; + return cachedRuntimes; } - // Use cached path if available - if (cachedBashPath !== null) { - return cachedBashPath; + // On Windows, detect WSL and Git for Windows + const available: BashRuntime[] = []; + + // Check for WSL (preferred - full Linux environment, wrapped in PowerShell to hide console) + const wslDistro = findWslDistro(); + if (wslDistro) { + available.push({ type: "wsl", distro: wslDistro }); + } + + // Check for Git for Windows (fallback) + const gitBashPath = findGitBash(); + if (gitBashPath) { + available.push({ type: "git-bash", bashPath: gitBashPath }); } - // Find bash on Windows - const bashPath = findWindowsBash(); - if (!bashPath) { + // Determine preferred runtime (first in list - WSL if available, else Git for Windows) + if (available.length === 0) { throw new Error( - "Git Bash not found. Please install Git for Windows from https://git-scm.com/download/win" + "No bash runtime found. Please install WSL (https://learn.microsoft.com/en-us/windows/wsl/install) or Git for Windows." ); } + const preferred = available[0]; + + cachedRuntimes = { available, preferred }; + return cachedRuntimes; +} + +// ============================================================================ +// Execution +// ============================================================================ + +/** + * Get spawn configuration for executing a script through the given runtime + * Always wraps commands in `bash -c "script"` + * + * For WSL runtime, Windows paths in the script are automatically translated + * to WSL paths (C:\... -> /mnt/c/...). + * + * @param runtime The bash runtime to use + * @param script The bash script to execute + * @param cwd Optional working directory (will be translated for WSL) + * @returns Command, args, and cwd suitable for child_process.spawn() + */ +export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: string): SpawnConfig { + switch (runtime.type) { + case "unix": + return { + command: "bash", + args: ["-c", script], + cwd, + }; + + case "git-bash": + return { + command: runtime.bashPath, + args: ["-c", script], + cwd, + }; + + case "wsl": { + // Translate Windows paths in the script for WSL + const translatedScript = translateWindowsPathsInCommand(script); + // Translate cwd for WSL - this goes INSIDE the bash script + const translatedCwd = cwd ? windowsToWslPath(cwd) : undefined; + + // Build the script that cd's to the right directory and runs the command + const cdPrefix = translatedCwd ? `cd '${translatedCwd}' && ` : ""; + const fullScript = cdPrefix + translatedScript; + + // Try to use PowerShell to hide WSL console window + // Use base64 encoding to completely avoid escaping issues with special chars + const psPath = getPowerShellPath(); + if (psPath) { + // Base64 encode the script to avoid any PowerShell parsing issues + const base64Script = Buffer.from(fullScript, "utf8").toString("base64"); + + // Pass the base64 string directly to bash, which decodes and executes it. + // This completely avoids quoting issues because: + // 1. Base64 only contains [A-Za-z0-9+/=] - no special chars + // 2. The decode happens inside bash, so PowerShell never sees the script + // 3. Single quotes in PowerShell pass the string literally to WSL + // + // Use process substitution: bash <(echo BASE64 | base64 -d) + // This creates a file descriptor containing the decoded script, so: + // - Bash reads the script from the file descriptor (not stdin) + // - The script can redirect stdin however it wants (e.g., exec