Skip to content

Commit 8053346

Browse files
committed
🤖 feat: use PowerShell + stdin for WSL to avoid escaping issues
- 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.
1 parent 7e61d95 commit 8053346

File tree

4 files changed

+136
-8
lines changed

4 files changed

+136
-8
lines changed

src/node/runtime/LocalBaseRuntime.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export abstract class LocalBaseRuntime implements Runtime {
7474
command: bashCommand,
7575
args: bashArgs,
7676
cwd: spawnCwd,
77+
stdin: stdinContent,
7778
} = getPreferredSpawnConfig(command, cwd);
7879

7980
// If niceness is specified on Unix/Linux, spawn nice directly to avoid escaping issues
@@ -103,6 +104,12 @@ export abstract class LocalBaseRuntime implements Runtime {
103104
windowsHide: true,
104105
});
105106

107+
// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
108+
if (stdinContent) {
109+
childProcess.stdin?.write(stdinContent);
110+
childProcess.stdin?.end();
111+
}
112+
106113
// Wrap in DisposableProcess for automatic cleanup
107114
const disposable = new DisposableProcess(childProcess);
108115

@@ -381,11 +388,13 @@ export abstract class LocalBaseRuntime implements Runtime {
381388
command: bashCommand,
382389
args: bashArgs,
383390
cwd: spawnCwd,
391+
stdin: stdinContent,
384392
} = getPreferredSpawnConfig(`"${hookPath}"`, workspacePath);
385393

386394
const proc = spawn(bashCommand, bashArgs, {
387395
cwd: spawnCwd,
388-
stdio: ["ignore", "pipe", "pipe"],
396+
// Use pipe for stdin if we need to send input (for WSL via PowerShell)
397+
stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"],
389398
env: {
390399
...process.env,
391400
...getInitHookEnv(projectPath, runtimeType),
@@ -394,11 +403,17 @@ export abstract class LocalBaseRuntime implements Runtime {
394403
windowsHide: true,
395404
});
396405

397-
proc.stdout.on("data", (data: Buffer) => {
406+
// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
407+
if (stdinContent && proc.stdin) {
408+
proc.stdin.write(stdinContent);
409+
proc.stdin.end();
410+
}
411+
412+
proc.stdout?.on("data", (data: Buffer) => {
398413
loggers.stdout.append(data.toString());
399414
});
400415

401-
proc.stderr.on("data", (data: Buffer) => {
416+
proc.stderr?.on("data", (data: Buffer) => {
402417
loggers.stderr.append(data.toString());
403418
});
404419

src/node/services/bashExecutionService.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export class BashExecutionService {
128128
command: bashCommand,
129129
args: bashArgs,
130130
cwd: spawnCwd,
131+
stdin: stdinContent,
131132
} = getPreferredSpawnConfig(script, config.cwd);
132133

133134
// Windows doesn't have nice command, so just spawn bash directly
@@ -141,7 +142,8 @@ export class BashExecutionService {
141142
const child = spawn(spawnCommand, spawnArgs, {
142143
cwd: spawnCwd,
143144
env: this.createBashEnvironment(config.secrets),
144-
stdio: ["ignore", "pipe", "pipe"],
145+
// Use pipe for stdin if we need to send input (for WSL via PowerShell)
146+
stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"],
145147
// Spawn as detached process group leader to prevent zombie processes
146148
// When bash spawns background processes, detached:true allows killing
147149
// the entire group via process.kill(-pid)
@@ -150,6 +152,12 @@ export class BashExecutionService {
150152
windowsHide: true,
151153
});
152154

155+
// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
156+
if (stdinContent && child.stdin) {
157+
child.stdin.write(stdinContent);
158+
child.stdin.end();
159+
}
160+
153161
log.debug(`BashExecutionService: Spawned process with PID ${child.pid ?? "unknown"}`);
154162

155163
// Line-by-line streaming with incremental buffers

src/node/utils/disposableExec.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,19 @@ class DisposableExec implements Disposable {
129129
export function execAsync(command: string): DisposableExec {
130130
// Wrap command in bash -c for consistent cross-platform behavior
131131
// For WSL, this also translates Windows paths to /mnt/... format
132-
const { command: bashCmd, args } = getPreferredSpawnConfig(command);
132+
const { command: bashCmd, args, stdin } = getPreferredSpawnConfig(command);
133133
const child = spawn(bashCmd, args, {
134-
stdio: ["ignore", "pipe", "pipe"],
134+
// Use pipe for stdin if we need to send input (for WSL via PowerShell)
135+
stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
135136
// Prevent console window from appearing on Windows
136137
windowsHide: true,
137138
});
139+
140+
// Write stdin content if provided (used for WSL via PowerShell to avoid escaping issues)
141+
if (stdin && child.stdin) {
142+
child.stdin.write(stdin);
143+
child.stdin.end();
144+
}
138145
const promise = new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
139146
let stdout = "";
140147
let stderr = "";

src/node/utils/main/bashPath.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export interface SpawnConfig {
5454
args: string[];
5555
/** Working directory (translated for WSL if needed) */
5656
cwd?: string;
57+
/** Optional stdin to pipe to the process (used for WSL to avoid escaping issues) */
58+
stdin?: string;
5759
}
5860

5961
// ============================================================================
@@ -199,6 +201,83 @@ function findWslDistro(): string | null {
199201
return null;
200202
}
201203

204+
/**
205+
* Find PowerShell executable path
206+
* We need the full path because Node.js spawn() may not have the same PATH as a user shell
207+
*/
208+
function findPowerShell(): string | null {
209+
// PowerShell Core (pwsh) locations - preferred as it's cross-platform
210+
const pwshPaths = [
211+
// PowerShell Core default installation
212+
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
213+
"C:\\Program Files\\PowerShell\\6\\pwsh.exe",
214+
// User-local installation
215+
path.join(process.env.LOCALAPPDATA ?? "", "Microsoft", "PowerShell", "pwsh.exe"),
216+
];
217+
218+
// Windows PowerShell (powershell.exe) - always present on Windows
219+
const windowsPowerShellPaths = [
220+
// 64-bit PowerShell
221+
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
222+
// 32-bit PowerShell (on 64-bit systems via SysWOW64)
223+
"C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe",
224+
];
225+
226+
// Try PowerShell Core first (better performance)
227+
for (const psPath of pwshPaths) {
228+
if (existsSync(psPath)) {
229+
return psPath;
230+
}
231+
}
232+
233+
// Try to find pwsh in PATH
234+
try {
235+
const result = execSync("where pwsh", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] });
236+
const firstPath = result.split("\n")[0].trim();
237+
if (firstPath && existsSync(firstPath)) {
238+
return firstPath;
239+
}
240+
} catch {
241+
// pwsh not in PATH
242+
}
243+
244+
// Fall back to Windows PowerShell
245+
for (const psPath of windowsPowerShellPaths) {
246+
if (existsSync(psPath)) {
247+
return psPath;
248+
}
249+
}
250+
251+
// Last resort: try to find powershell in PATH
252+
try {
253+
const result = execSync("where powershell", {
254+
encoding: "utf8",
255+
stdio: ["pipe", "pipe", "ignore"],
256+
});
257+
const firstPath = result.split("\n")[0].trim();
258+
if (firstPath && existsSync(firstPath)) {
259+
return firstPath;
260+
}
261+
} catch {
262+
// powershell not in PATH
263+
}
264+
265+
return null;
266+
}
267+
268+
// Cached PowerShell path (set during runtime detection)
269+
let cachedPowerShellPath: string | null | undefined = undefined;
270+
271+
/**
272+
* Get the PowerShell path, detecting it if not yet cached
273+
*/
274+
function getPowerShellPath(): string | null {
275+
if (cachedPowerShellPath === undefined) {
276+
cachedPowerShellPath = findPowerShell();
277+
}
278+
return cachedPowerShellPath;
279+
}
280+
202281
/**
203282
* Detect all available bash runtimes on the current system
204283
* Results are cached for performance
@@ -280,14 +359,33 @@ export function getSpawnConfig(runtime: BashRuntime, script: string, cwd?: strin
280359
case "wsl": {
281360
// Translate Windows paths in the script for WSL
282361
const translatedScript = translateWindowsPathsInCommand(script);
283-
// Translate cwd for WSL - this goes INSIDE the bash command, not to cmd.exe
362+
// Translate cwd for WSL - this goes INSIDE the bash script
284363
const translatedCwd = cwd ? windowsToWslPath(cwd) : undefined;
285364

286365
// Build the script that cd's to the right directory and runs the command
287366
const cdPrefix = translatedCwd ? `cd '${translatedCwd}' && ` : "";
288367
const fullScript = cdPrefix + translatedScript;
289368

290-
// Build the WSL command args
369+
// Try to use PowerShell to hide WSL console window
370+
// Pass the script via stdin to avoid PowerShell escaping issues with special chars
371+
const psPath = getPowerShellPath();
372+
if (psPath) {
373+
// Build WSL command to read script from stdin
374+
const wslCmd = runtime.distro ? `wsl -d ${runtime.distro} bash` : "wsl bash";
375+
376+
// PowerShell will receive the script via stdin and pipe it to WSL
377+
// -NoProfile: faster startup, avoids profile execution policy issues
378+
// -WindowStyle Hidden: hide console window
379+
// -Command: execute the piped command
380+
return {
381+
command: psPath,
382+
args: ["-NoProfile", "-WindowStyle", "Hidden", "-Command", `$input | ${wslCmd}`],
383+
cwd: undefined, // cwd is embedded in the script
384+
stdin: fullScript,
385+
};
386+
}
387+
388+
// Fallback: direct WSL invocation (console window may flash)
291389
const wslArgs: string[] = [];
292390
if (runtime.distro) {
293391
wslArgs.push("-d", runtime.distro);

0 commit comments

Comments
 (0)