From 7e61d953d6b5e3d1c63d04049b4aeaccbbb0de0f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:51:37 +0000 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=A4=96=20feat:=20improve=20Windows?= =?UTF-8?q?=20bash=20runtime=20detection=20with=20WSL=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BashRuntime types (GitBashRuntime, WslRuntime, UnixBashRuntime) - Detect available runtimes: WSL (preferred) and Git for Windows (fallback) - Automatic Windows path translation for WSL (C:\ -> /mnt/c/) - cwd embedded in script via 'cd' since WSL needs Linux paths - Use -- separator to avoid WSL arg parsing issues - Use absolute paths to bash for each runtime - Always wrap commands in bash -c for consistent behavior - Update BashExecutionService, execAsync, and LocalBaseRuntime - Add comprehensive tests for runtime detection and path translation _Generated with mux_ --- docs/system-prompt.md | 1 - src/node/runtime/LocalBaseRuntime.ts | 33 ++- src/node/services/bashExecutionService.ts | 20 +- src/node/utils/disposableExec.ts | 16 +- src/node/utils/main/bashPath.test.ts | 217 ++++++++++++++++ src/node/utils/main/bashPath.ts | 297 ++++++++++++++++++++-- 6 files changed, 539 insertions(+), 45 deletions(-) create mode 100644 src/node/utils/main/bashPath.test.ts 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..f7ab852ae 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -18,7 +18,7 @@ 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"; @@ -67,18 +67,26 @@ 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); + // 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; const childProcess = spawn(spawnCommand, spawnArgs, { - cwd, + cwd: spawnCwd, env: { ...process.env, ...(options.env ?? {}), @@ -367,9 +375,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, 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..7c1541b63 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -1,5 +1,6 @@ -import { exec } from "child_process"; +import { spawn } from "child_process"; import type { ChildProcess } from "child_process"; +import { getPreferredSpawnConfig } from "@/node/utils/main/bashPath"; /** * Disposable wrapper for child processes that ensures immediate cleanup. @@ -117,12 +118,23 @@ 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); + const child = spawn(bashCmd, args, { + stdio: ["ignore", "pipe", "pipe"], + // Prevent console window from appearing on Windows + windowsHide: true, + }); const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { let stdout = ""; let 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..9952db211 --- /dev/null +++ b/src/node/utils/main/bashPath.test.ts @@ -0,0 +1,217 @@ +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'); + }); + }); + + 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..a4425d65d 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -1,21 +1,119 @@ /** - * 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 +// ============================================================================ /** - * Find bash executable path on Windows - * Checks common Git Bash installation locations + * Convert a Windows path to a WSL path + * C:\Users\name -> /mnt/c/Users/name + * D:\Projects -> /mnt/d/Projects */ -function findWindowsBash(): string | null { +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 or without quotes) + // Handles: C:\path, "C:\path", 'C:\path' + return command.replace( + /(["']?)([a-zA-Z]):[/\\]([^"'\s]*)\1/g, + (_match: string, quote: string, drive: string, rest: string) => { + const wslPath = `/mnt/${drive.toLowerCase()}/${rest.replace(/\\/g, "/")}`; + return quote ? `${quote}${wslPath}${quote}` : 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 Git for Windows bash.exe path + * Checks common installation locations + */ +function findGitBash(): string | null { // Common Git Bash installation paths const commonPaths = [ // Git for Windows default paths @@ -23,17 +121,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 +171,177 @@ 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; +} + +/** + * 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 command, not to cmd.exe + 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; + + // Build the WSL command args + const wslArgs: string[] = []; + if (runtime.distro) { + wslArgs.push("-d", runtime.distro); + } + // Use -- to separate wsl args from bash args, avoiding parsing issues + wslArgs.push("--", "bash", "-c", fullScript); + + return { + command: "wsl", + args: wslArgs, + // cwd is handled via 'cd' inside the bash script since WSL needs Linux paths + cwd: undefined, + }; + } + } +} + +/** + * Get spawn configuration using the preferred runtime + * Convenience wrapper around detectBashRuntimes() + getSpawnConfig() + * + * @param script The bash script to execute + * @param cwd Optional working directory (will be translated for WSL on Windows) + */ +export function getPreferredSpawnConfig(script: string, cwd?: string): SpawnConfig { + const { preferred } = detectBashRuntimes(); + return getSpawnConfig(preferred, script, cwd); +} + +// ============================================================================ +// Legacy API (for backward compatibility) +// ============================================================================ - cachedBashPath = bashPath; - return bashPath; +/** + * Get the bash executable path for the current platform + * + * @deprecated Use detectBashRuntimes() and getSpawnConfig() instead + * @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 + */ +export function getBashPath(): string { + const { preferred } = detectBashRuntimes(); + + switch (preferred.type) { + case "unix": + return "bash"; + case "git-bash": + return preferred.bashPath; + case "wsl": + // For legacy API, return "wsl" - callers will need to update to new API + // This is a breaking change signal + return "wsl"; + } } /** @@ -108,7 +351,7 @@ export function getBashPath(): string { */ export function isBashAvailable(): boolean { try { - getBashPath(); + detectBashRuntimes(); return true; } catch { return false; From 80533461727080ec2ce9644db2bb280c3a13f968 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:37:30 +0000 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=A4=96=20feat:=20use=20PowerShell?= =?UTF-8?q?=20+=20stdin=20for=20WSL=20to=20avoid=20escaping=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass bash script via stdin instead of command-line args to avoid PowerShell mangling special characters like %(refname:short) - Add comprehensive PowerShell path detection (pwsh + Windows PowerShell) - Add stdin field to SpawnConfig interface for callers to pipe to process - Update all spawn sites to handle stdin content This approach completely sidesteps command-line escaping issues because the script content never touches PowerShell's argument parser. --- src/node/runtime/LocalBaseRuntime.ts | 21 ++++- src/node/services/bashExecutionService.ts | 10 ++- src/node/utils/disposableExec.ts | 11 ++- src/node/utils/main/bashPath.ts | 102 +++++++++++++++++++++- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index f7ab852ae..e94240e97 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -74,6 +74,7 @@ export abstract class LocalBaseRuntime implements Runtime { command: bashCommand, args: bashArgs, cwd: spawnCwd, + stdin: stdinContent, } = getPreferredSpawnConfig(command, cwd); // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues @@ -103,6 +104,12 @@ export abstract class LocalBaseRuntime implements Runtime { windowsHide: true, }); + // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) + if (stdinContent) { + childProcess.stdin?.write(stdinContent); + childProcess.stdin?.end(); + } + // Wrap in DisposableProcess for automatic cleanup const disposable = new DisposableProcess(childProcess); @@ -381,11 +388,13 @@ export abstract class LocalBaseRuntime implements Runtime { command: bashCommand, args: bashArgs, cwd: spawnCwd, + stdin: stdinContent, } = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath); const proc = spawn(bashCommand, bashArgs, { cwd: spawnCwd, - stdio: ["ignore", "pipe", "pipe"], + // Use pipe for stdin if we need to send input (for WSL via PowerShell) + stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"], env: { ...process.env, ...getInitHookEnv(projectPath, runtimeType), @@ -394,11 +403,17 @@ export abstract class LocalBaseRuntime implements Runtime { windowsHide: true, }); - proc.stdout.on("data", (data: Buffer) => { + // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) + if (stdinContent && proc.stdin) { + proc.stdin.write(stdinContent); + proc.stdin.end(); + } + + 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 17173fb4d..397c0b235 100644 --- a/src/node/services/bashExecutionService.ts +++ b/src/node/services/bashExecutionService.ts @@ -128,6 +128,7 @@ export class BashExecutionService { command: bashCommand, args: bashArgs, cwd: spawnCwd, + stdin: stdinContent, } = getPreferredSpawnConfig(script, config.cwd); // Windows doesn't have nice command, so just spawn bash directly @@ -141,7 +142,8 @@ export class BashExecutionService { const child = spawn(spawnCommand, spawnArgs, { cwd: spawnCwd, env: this.createBashEnvironment(config.secrets), - stdio: ["ignore", "pipe", "pipe"], + // Use pipe for stdin if we need to send input (for WSL via PowerShell) + stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"], // Spawn as detached process group leader to prevent zombie processes // When bash spawns background processes, detached:true allows killing // the entire group via process.kill(-pid) @@ -150,6 +152,12 @@ export class BashExecutionService { windowsHide: true, }); + // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) + if (stdinContent && child.stdin) { + child.stdin.write(stdinContent); + child.stdin.end(); + } + log.debug(`BashExecutionService: Spawned process with PID ${child.pid ?? "unknown"}`); // Line-by-line streaming with incremental buffers diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index 7c1541b63..8224878d7 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -129,12 +129,19 @@ class DisposableExec implements Disposable { export function execAsync(command: string): DisposableExec { // 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); + const { command: bashCmd, args, stdin } = getPreferredSpawnConfig(command); const child = spawn(bashCmd, args, { - stdio: ["ignore", "pipe", "pipe"], + // Use pipe for stdin if we need to send input (for WSL via PowerShell) + stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"], // Prevent console window from appearing on Windows windowsHide: true, }); + + // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) + if (stdin && child.stdin) { + child.stdin.write(stdin); + child.stdin.end(); + } const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { let stdout = ""; let stderr = ""; diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index a4425d65d..21ec2fbcb 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -54,6 +54,8 @@ export interface SpawnConfig { args: string[]; /** Working directory (translated for WSL if needed) */ cwd?: string; + /** Optional stdin to pipe to the process (used for WSL to avoid escaping issues) */ + stdin?: string; } // ============================================================================ @@ -199,6 +201,83 @@ function findWslDistro(): string | null { 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 @@ -280,14 +359,33 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin case "wsl": { // Translate Windows paths in the script for WSL const translatedScript = translateWindowsPathsInCommand(script); - // Translate cwd for WSL - this goes INSIDE the bash command, not to cmd.exe + // 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; - // Build the WSL command args + // Try to use PowerShell to hide WSL console window + // Pass the script via stdin to avoid PowerShell escaping issues with special chars + const psPath = getPowerShellPath(); + if (psPath) { + // Build WSL command to read script from stdin + const wslCmd = runtime.distro ? `wsl -d ${runtime.distro} bash` : "wsl bash"; + + // PowerShell will receive the script via stdin and pipe it to WSL + // -NoProfile: faster startup, avoids profile execution policy issues + // -WindowStyle Hidden: hide console window + // -Command: execute the piped command + return { + command: psPath, + args: ["-NoProfile", "-WindowStyle", "Hidden", "-Command", `$input | ${wslCmd}`], + cwd: undefined, // cwd is embedded in the script + stdin: fullScript, + }; + } + + // Fallback: direct WSL invocation (console window may flash) const wslArgs: string[] = []; if (runtime.distro) { wslArgs.push("-d", runtime.distro); From 1eaf990d3c3a70145e685cfcc7bfd83ab93090d3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:49:24 +0000 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20[Console]::In?= =?UTF-8?q?.ReadToEnd()=20for=20PowerShell=20stdin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerShell's $input automatic variable doesn't work reliably with -Command because it's designed for pipeline input in functions/filters, not for reading stdin directly. Use [Console]::In.ReadToEnd() instead. --- src/node/utils/main/bashPath.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index 21ec2fbcb..c7a4c66fb 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -377,9 +377,17 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin // -NoProfile: faster startup, avoids profile execution policy issues // -WindowStyle Hidden: hide console window // -Command: execute the piped command + // Use [Console]::In.ReadToEnd() instead of $input because $input doesn't work + // reliably with -Command (it's meant for pipeline input in functions/filters) return { command: psPath, - args: ["-NoProfile", "-WindowStyle", "Hidden", "-Command", `$input | ${wslCmd}`], + args: [ + "-NoProfile", + "-WindowStyle", + "Hidden", + "-Command", + `[Console]::In.ReadToEnd() | ${wslCmd}`, + ], cwd: undefined, // cwd is embedded in the script stdin: fullScript, }; From 3a51bab4c9e45d123321c26e0bc7587645a54b9c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:55:23 +0000 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20handle=20quoted=20W?= =?UTF-8?q?indows=20paths=20with=20spaces=20in=20translation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed regex to properly handle paths like "C:\Users\John Doe\repo": - Double-quoted paths: match content up to closing quote (allows spaces) - Single-quoted paths: match content up to closing quote (allows spaces) - Unquoted paths: match up to whitespace (no spaces allowed) Added 3 new tests for paths with spaces. --- src/node/utils/main/bashPath.test.ts | 18 +++++++++++++++ src/node/utils/main/bashPath.ts | 33 +++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/node/utils/main/bashPath.test.ts b/src/node/utils/main/bashPath.test.ts index 9952db211..99d096167 100644 --- a/src/node/utils/main/bashPath.test.ts +++ b/src/node/utils/main/bashPath.test.ts @@ -173,6 +173,24 @@ describe("bashPath", () => { 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", () => { diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index c7a4c66fb..a2241a7ff 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -84,13 +84,34 @@ export function windowsToWslPath(windowsPath: string): string { * Finds patterns like C:\... or "C:\..." and converts them */ export function translateWindowsPathsInCommand(command: string): string { - // Match Windows paths (with or without quotes) - // Handles: C:\path, "C:\path", 'C:\path' + // 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]):[/\\]([^"'\s]*)\1/g, - (_match: string, quote: string, drive: string, rest: string) => { - const wslPath = `/mnt/${drive.toLowerCase()}/${rest.replace(/\\/g, "/")}`; - return quote ? `${quote}${wslPath}${quote}` : wslPath; + /"([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; + } } ); } From e2e7f3f7bafc6cf040623ad300d0a7f5cb2ba107 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:59:37 +0000 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20base64=20enco?= =?UTF-8?q?ding=20instead=20of=20stdin=20for=20PowerShell=20WSL=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stdin piping to PowerShell didn't work reliably ($input and [Console]::In both failed). Instead, use base64 encoding to embed the script safely in the PowerShell command line: 1. Base64 encode the bash script (completely avoids escaping issues) 2. PowerShell decodes it with [System.Text.Encoding]::UTF8.GetString() 3. Echo the decoded script and pipe to WSL bash This approach is robust because base64 strings only contain [A-Za-z0-9+/=] which have no special meaning in PowerShell. Also removes the now-unused stdin field from SpawnConfig. --- src/node/runtime/LocalBaseRuntime.ts | 17 +----------- src/node/services/bashExecutionService.ts | 10 +------ src/node/utils/disposableExec.ts | 11 ++------ src/node/utils/main/bashPath.ts | 33 +++++++++-------------- 4 files changed, 17 insertions(+), 54 deletions(-) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index e94240e97..9453f1b55 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -74,7 +74,6 @@ export abstract class LocalBaseRuntime implements Runtime { command: bashCommand, args: bashArgs, cwd: spawnCwd, - stdin: stdinContent, } = getPreferredSpawnConfig(command, cwd); // If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues @@ -104,12 +103,6 @@ export abstract class LocalBaseRuntime implements Runtime { windowsHide: true, }); - // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) - if (stdinContent) { - childProcess.stdin?.write(stdinContent); - childProcess.stdin?.end(); - } - // Wrap in DisposableProcess for automatic cleanup const disposable = new DisposableProcess(childProcess); @@ -388,13 +381,11 @@ export abstract class LocalBaseRuntime implements Runtime { command: bashCommand, args: bashArgs, cwd: spawnCwd, - stdin: stdinContent, } = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath); const proc = spawn(bashCommand, bashArgs, { cwd: spawnCwd, - // Use pipe for stdin if we need to send input (for WSL via PowerShell) - stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...getInitHookEnv(projectPath, runtimeType), @@ -403,12 +394,6 @@ export abstract class LocalBaseRuntime implements Runtime { windowsHide: true, }); - // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) - if (stdinContent && proc.stdin) { - proc.stdin.write(stdinContent); - proc.stdin.end(); - } - proc.stdout?.on("data", (data: Buffer) => { loggers.stdout.append(data.toString()); }); diff --git a/src/node/services/bashExecutionService.ts b/src/node/services/bashExecutionService.ts index 397c0b235..17173fb4d 100644 --- a/src/node/services/bashExecutionService.ts +++ b/src/node/services/bashExecutionService.ts @@ -128,7 +128,6 @@ export class BashExecutionService { command: bashCommand, args: bashArgs, cwd: spawnCwd, - stdin: stdinContent, } = getPreferredSpawnConfig(script, config.cwd); // Windows doesn't have nice command, so just spawn bash directly @@ -142,8 +141,7 @@ export class BashExecutionService { const child = spawn(spawnCommand, spawnArgs, { cwd: spawnCwd, env: this.createBashEnvironment(config.secrets), - // Use pipe for stdin if we need to send input (for WSL via PowerShell) - stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], // Spawn as detached process group leader to prevent zombie processes // When bash spawns background processes, detached:true allows killing // the entire group via process.kill(-pid) @@ -152,12 +150,6 @@ export class BashExecutionService { windowsHide: true, }); - // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) - if (stdinContent && child.stdin) { - child.stdin.write(stdinContent); - child.stdin.end(); - } - log.debug(`BashExecutionService: Spawned process with PID ${child.pid ?? "unknown"}`); // Line-by-line streaming with incremental buffers diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index 8224878d7..7c1541b63 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -129,19 +129,12 @@ class DisposableExec implements Disposable { export function execAsync(command: string): DisposableExec { // Wrap command in bash -c for consistent cross-platform behavior // For WSL, this also translates Windows paths to /mnt/... format - const { command: bashCmd, args, stdin } = getPreferredSpawnConfig(command); + const { command: bashCmd, args } = getPreferredSpawnConfig(command); const child = spawn(bashCmd, args, { - // Use pipe for stdin if we need to send input (for WSL via PowerShell) - stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"], // Prevent console window from appearing on Windows windowsHide: true, }); - - // Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues) - if (stdin && child.stdin) { - child.stdin.write(stdin); - child.stdin.end(); - } const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { let stdout = ""; let stderr = ""; diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index a2241a7ff..1ff076906 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -54,8 +54,6 @@ export interface SpawnConfig { args: string[]; /** Working directory (translated for WSL if needed) */ cwd?: string; - /** Optional stdin to pipe to the process (used for WSL to avoid escaping issues) */ - stdin?: string; } // ============================================================================ @@ -388,29 +386,24 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin const fullScript = cdPrefix + translatedScript; // Try to use PowerShell to hide WSL console window - // Pass the script via stdin to avoid PowerShell escaping issues with special chars + // Use base64 encoding to completely avoid escaping issues with special chars const psPath = getPowerShellPath(); if (psPath) { - // Build WSL command to read script from stdin - const wslCmd = runtime.distro ? `wsl -d ${runtime.distro} bash` : "wsl bash"; - - // PowerShell will receive the script via stdin and pipe it to WSL - // -NoProfile: faster startup, avoids profile execution policy issues - // -WindowStyle Hidden: hide console window - // -Command: execute the piped command - // Use [Console]::In.ReadToEnd() instead of $input because $input doesn't work - // reliably with -Command (it's meant for pipeline input in functions/filters) + // Base64 encode the script to avoid any PowerShell parsing issues + // PowerShell will decode it and pipe to WSL bash + const base64Script = Buffer.from(fullScript, "utf8").toString("base64"); + + // Build the PowerShell command that: + // 1. Decodes the base64 script + // 2. Pipes it to WSL bash via echo + const wslArgs = runtime.distro ? `-d ${runtime.distro}` : ""; + const psCommand = `$s=[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${base64Script}'));` + + `echo $s | wsl ${wslArgs} bash`.trim(); + return { command: psPath, - args: [ - "-NoProfile", - "-WindowStyle", - "Hidden", - "-Command", - `[Console]::In.ReadToEnd() | ${wslCmd}`, - ], + args: ["-NoProfile", "-WindowStyle", "Hidden", "-Command", psCommand], cwd: undefined, // cwd is embedded in the script - stdin: fullScript, }; } From accb21f003abce8f97c7649a93a5bd1f15aeb466 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:05:11 +0000 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=A4=96=20debug:=20add=20logging=20t?= =?UTF-8?q?o=20execAsync=20to=20trace=20WSL=20command=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/utils/disposableExec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/node/utils/disposableExec.ts b/src/node/utils/disposableExec.ts index 7c1541b63..d732e17c1 100644 --- a/src/node/utils/disposableExec.ts +++ b/src/node/utils/disposableExec.ts @@ -1,6 +1,7 @@ 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. @@ -130,11 +131,20 @@ export function execAsync(command: string): DisposableExec { // 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 = ""; @@ -153,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 }); From dcc7f7703f085c96af10f69beae1c7412a01b1ad Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:12:49 +0000 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20bash=20-c=20i?= =?UTF-8?q?nstead=20of=20stdin=20piping=20to=20capture=20WSL=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using 'echo $s | wsl bash', the output from bash wasn't being captured by PowerShell. Change to 'wsl bash -c $s' which passes the script as an argument and properly captures stdout/stderr. --- src/node/utils/main/bashPath.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index 1ff076906..1a6123e55 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -390,15 +390,18 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin const psPath = getPowerShellPath(); if (psPath) { // Base64 encode the script to avoid any PowerShell parsing issues - // PowerShell will decode it and pipe to WSL bash + // PowerShell will decode it and pass to WSL bash -c const base64Script = Buffer.from(fullScript, "utf8").toString("base64"); // Build the PowerShell command that: - // 1. Decodes the base64 script - // 2. Pipes it to WSL bash via echo + // 1. Decodes the base64 script into variable $s + // 2. Passes $s to WSL bash -c (not stdin piping - that loses output) const wslArgs = runtime.distro ? `-d ${runtime.distro}` : ""; - const psCommand = `$s=[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${base64Script}'));` + - `echo $s | wsl ${wslArgs} bash`.trim(); + // Use Invoke-Expression to run the wsl command with the decoded script + // This ensures stdout/stderr are properly captured + const psCommand = + `$s=[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${base64Script}'));` + + `wsl ${wslArgs} bash -c $s`.trim(); return { command: psPath, From c0fde480a311b2e60cfe3e501aa01067a32733bb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:20:29 +0000 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20quote=20$s=20variab?= =?UTF-8?q?le=20when=20passing=20to=20bash=20-c?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without quotes, PowerShell expands $s and bash only receives the first word as the -c argument. The rest becomes positional args to bash. Use escaped double quotes (\`"$s\`") so the entire decoded script is passed as a single argument to bash -c. --- src/node/utils/main/bashPath.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index 1a6123e55..df6fba275 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -395,13 +395,14 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin // Build the PowerShell command that: // 1. Decodes the base64 script into variable $s - // 2. Passes $s to WSL bash -c (not stdin piping - that loses output) + // 2. Passes $s to WSL bash -c with proper quoting const wslArgs = runtime.distro ? `-d ${runtime.distro}` : ""; - // Use Invoke-Expression to run the wsl command with the decoded script - // This ensures stdout/stderr are properly captured + // CRITICAL: Must quote $s with escaped double quotes (`"$s`") so the entire + // script is passed as a single argument to bash -c. Without quotes, PowerShell + // expands $s and bash only sees the first word as the -c argument. const psCommand = `$s=[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${base64Script}'));` + - `wsl ${wslArgs} bash -c $s`.trim(); + `wsl ${wslArgs} bash -c \`"$s\`"`.trim(); return { command: psPath, From 45e8ec16c58a356d2d6d8020520a7d25f82ea8c9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:28:30 +0000 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20have=20bash=20decod?= =?UTF-8?q?e=20base64=20to=20avoid=20all=20quoting=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach had PowerShell decode base64 then pass to bash -c, but the decoded script contained double quotes that conflicted with the outer quoting. New approach: pass base64 string directly to bash which decodes it: wsl bash -c 'echo BASE64 | base64 -d | bash' This works because: 1. Base64 only contains [A-Za-z0-9+/=] - no special chars 2. The decode happens inside bash, so no quoting issues 3. Single quotes pass the string literally through PowerShell to WSL --- src/node/utils/main/bashPath.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/node/utils/main/bashPath.ts b/src/node/utils/main/bashPath.ts index df6fba275..7896e9970 100644 --- a/src/node/utils/main/bashPath.ts +++ b/src/node/utils/main/bashPath.ts @@ -390,19 +390,15 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin const psPath = getPowerShellPath(); if (psPath) { // Base64 encode the script to avoid any PowerShell parsing issues - // PowerShell will decode it and pass to WSL bash -c const base64Script = Buffer.from(fullScript, "utf8").toString("base64"); - // Build the PowerShell command that: - // 1. Decodes the base64 script into variable $s - // 2. Passes $s to WSL bash -c with proper quoting + // 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 const wslArgs = runtime.distro ? `-d ${runtime.distro}` : ""; - // CRITICAL: Must quote $s with escaped double quotes (`"$s`") so the entire - // script is passed as a single argument to bash -c. Without quotes, PowerShell - // expands $s and bash only sees the first word as the -c argument. - const psCommand = - `$s=[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${base64Script}'));` + - `wsl ${wslArgs} bash -c \`"$s\`"`.trim(); + const psCommand = `wsl ${wslArgs} bash -c 'echo ${base64Script} | base64 -d | bash'`.trim(); return { command: psPath, From fc250b6043843ff9354e4c76a8161902595897bc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:31:46 +0000 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=A4=96=20debug:=20add=20logging=20t?= =?UTF-8?q?o=20LocalBaseRuntime.exec=20for=20WSL=20tracing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/runtime/LocalBaseRuntime.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 9453f1b55..00ce78ec4 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -22,6 +22,7 @@ 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, @@ -76,6 +77,13 @@ export abstract class LocalBaseRuntime implements Runtime { cwd: spawnCwd, } = getPreferredSpawnConfig(command, cwd); + // Debug logging for Windows WSL issues + log.info(`[LocalBaseRuntime.exec] Original command: ${command}`); + log.info(`[LocalBaseRuntime.exec] Original cwd: ${cwd}`); + log.info(`[LocalBaseRuntime.exec] Spawn command: ${bashCommand}`); + log.info(`[LocalBaseRuntime.exec] Spawn args: ${JSON.stringify(bashArgs)}`); + log.info(`[LocalBaseRuntime.exec] Spawn cwd: ${spawnCwd}`); + // 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"; From 6171fef998a7500b2624ba5fe903fbd89fe80daf Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:35:01 +0000 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20disable=20detached?= =?UTF-8?q?=20mode=20on=20Windows=20for=20PowerShell=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, detached:true creates a separate console window which interferes with output capture when combined with PowerShell's -WindowStyle Hidden. The detached option is primarily needed on Unix for process group management. On Windows with the PowerShell wrapper, we don't need it and it actively breaks stdout/stderr capture. --- src/node/runtime/LocalBaseRuntime.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 00ce78ec4..0adf62aa8 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -93,6 +93,11 @@ export abstract class LocalBaseRuntime implements Runtime { ? ["-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: spawnCwd, env: { @@ -106,7 +111,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, }); From 6830ba38080e5e155349f936966bdb6e60a14f71 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:41:33 +0000 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=A4=96=20debug:=20add=20exit/output?= =?UTF-8?q?=20logging=20to=20LocalBaseRuntime.exec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/runtime/LocalBaseRuntime.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 0adf62aa8..df8d9bde7 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -132,6 +132,16 @@ export abstract class LocalBaseRuntime implements Runtime { let timedOut = false; let aborted = false; + // Debug: log raw stdout/stderr from the child process + let debugStdout = ""; + let debugStderr = ""; + 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) => { @@ -140,6 +150,12 @@ 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) => { + 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) { From 5abbe099d50ee946ad6c1e2aa3f449d1f238b6d8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:44:19 +0000 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=A4=96=20debug:=20filter=20out=20no?= =?UTF-8?q?isy=20git=20status=20commands=20from=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/runtime/LocalBaseRuntime.ts | 41 ++++++++++++++++------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index df8d9bde7..9f966ab3f 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -77,12 +77,13 @@ export abstract class LocalBaseRuntime implements Runtime { cwd: spawnCwd, } = getPreferredSpawnConfig(command, cwd); - // Debug logging for Windows WSL issues - log.info(`[LocalBaseRuntime.exec] Original command: ${command}`); - log.info(`[LocalBaseRuntime.exec] Original cwd: ${cwd}`); - log.info(`[LocalBaseRuntime.exec] Spawn command: ${bashCommand}`); - log.info(`[LocalBaseRuntime.exec] Spawn args: ${JSON.stringify(bashArgs)}`); - log.info(`[LocalBaseRuntime.exec] Spawn cwd: ${spawnCwd}`); + // 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 @@ -132,15 +133,17 @@ export abstract class LocalBaseRuntime implements Runtime { let timedOut = false; let aborted = false; - // Debug: log raw stdout/stderr from the child process + // Debug: log raw stdout/stderr from the child process (only for non-git-status commands) let debugStdout = ""; let debugStderr = ""; - childProcess.stdout?.on("data", (chunk: Buffer) => { - debugStdout += chunk.toString(); - }); - childProcess.stderr?.on("data", (chunk: Buffer) => { - debugStderr += chunk.toString(); - }); + 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 @@ -150,11 +153,13 @@ 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) => { - 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 ? "..." : ""}`); + 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 From 7a5288a8d6e0db274b9516b0b16ede8a416a836f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:47:32 +0000 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20eval=20with?= =?UTF-8?q?=20command=20substitution=20to=20avoid=20stdin=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts containing 'exec Date: Fri, 5 Dec 2025 18:58:37 +0000 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20process=20sub?= =?UTF-8?q?stitution=20to=20avoid=20quoting=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eval "$(...)" had nested quote conflicts with commands containing double quotes like --format="%(refname:short)". Use process substitution instead: bash <(echo BASE64 | base64 -d) This creates a file descriptor containing the decoded script, so: - Bash reads script from file descriptor (not stdin or quoted arg) - No quoting conflicts because script content isn't in quotes - Script can still redirect stdin freely (exec