Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
219 lines
6.5 KiB
TypeScript
219 lines
6.5 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { delimiter, join } from "node:path";
|
|
import { spawn, spawnSync } from "child_process";
|
|
import { getBinDir } from "../config.ts";
|
|
|
|
export interface ShellConfig {
|
|
shell: string;
|
|
args: string[];
|
|
commandTransport?: "argv" | "stdin";
|
|
}
|
|
|
|
/**
|
|
* Find bash executable on PATH (cross-platform)
|
|
*/
|
|
function isLegacyWslBashPath(path: string): boolean {
|
|
const normalized = path.replace(/\//g, "\\").toLowerCase();
|
|
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
|
|
}
|
|
|
|
function getBashShellConfig(shell: string): ShellConfig {
|
|
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
|
|
}
|
|
|
|
function findExecutableOnPath(executable: string): string | null {
|
|
if (process.platform === "win32") {
|
|
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
|
|
try {
|
|
const result = spawnSync("where", [executable], {
|
|
encoding: "utf-8",
|
|
timeout: 5000,
|
|
windowsHide: true,
|
|
});
|
|
if (result.status === 0 && result.stdout) {
|
|
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
|
if (firstMatch && existsSync(firstMatch)) {
|
|
return firstMatch;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Unix: Use 'which' and trust its output (handles Termux and special filesystems)
|
|
try {
|
|
const result = spawnSync("which", [executable], { encoding: "utf-8", timeout: 5000 });
|
|
if (result.status === 0 && result.stdout) {
|
|
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
|
if (firstMatch) {
|
|
return firstMatch;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve shell configuration based on platform and an optional explicit shell path.
|
|
* Resolution order:
|
|
* 1. User-specified shellPath
|
|
* 2. On Windows: Git Bash in known locations, then bash on PATH
|
|
* 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh
|
|
*/
|
|
export function getShellConfig(customShellPath?: string): ShellConfig {
|
|
// 1. Check user-specified shell path
|
|
if (customShellPath) {
|
|
if (existsSync(customShellPath)) {
|
|
return getBashShellConfig(customShellPath);
|
|
}
|
|
throw new Error(`Custom shell path not found: ${customShellPath}`);
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
// 2. Try Git Bash in known locations
|
|
const paths: string[] = [];
|
|
const programFiles = process.env.ProgramFiles;
|
|
if (programFiles) {
|
|
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
}
|
|
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
|
if (programFilesX86) {
|
|
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
}
|
|
|
|
for (const path of paths) {
|
|
if (existsSync(path)) {
|
|
return getBashShellConfig(path);
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
const bashOnPath = findExecutableOnPath("bash.exe");
|
|
if (bashOnPath) {
|
|
return getBashShellConfig(bashOnPath);
|
|
}
|
|
|
|
throw new Error(
|
|
`No bash shell found. Options:\n` +
|
|
` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
|
|
` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
|
|
" 3. Set shellPath in settings.json\n\n" +
|
|
`Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`,
|
|
);
|
|
}
|
|
|
|
// Unix: try /bin/bash, then bash on PATH, then fallback to sh
|
|
if (existsSync("/bin/bash")) {
|
|
return getBashShellConfig("/bin/bash");
|
|
}
|
|
|
|
const bashOnPath = findExecutableOnPath("bash");
|
|
if (bashOnPath) {
|
|
return getBashShellConfig(bashOnPath);
|
|
}
|
|
|
|
return { shell: "sh", args: ["-c"] };
|
|
}
|
|
|
|
export const POWERSHELL_ARGS = ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"] as const;
|
|
|
|
/** Resolve PowerShell on Windows, preferring PowerShell 7 when available. */
|
|
export function getPowerShellConfig(): ShellConfig {
|
|
if (process.platform !== "win32") {
|
|
throw new Error("The powershell tool is only available on Windows.");
|
|
}
|
|
|
|
const shell = findExecutableOnPath("pwsh.exe") ?? findExecutableOnPath("powershell.exe");
|
|
if (!shell) {
|
|
throw new Error("No PowerShell executable found. Install PowerShell or add powershell.exe/pwsh.exe to PATH.");
|
|
}
|
|
|
|
return { shell, args: [...POWERSHELL_ARGS] };
|
|
}
|
|
|
|
export function getShellEnv(): NodeJS.ProcessEnv {
|
|
const binDir = getBinDir();
|
|
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
const currentPath = process.env[pathKey] ?? "";
|
|
const pathEntries = currentPath.split(delimiter).filter(Boolean);
|
|
const hasBinDir = pathEntries.includes(binDir);
|
|
const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter);
|
|
|
|
return {
|
|
...process.env,
|
|
[pathKey]: updatedPath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sanitize binary output for display/storage.
|
|
* Removes characters that crash string-width or cause display issues:
|
|
* - Control characters (except tab, newline, carriage return)
|
|
* - Unicode interlinear annotation characters U+FFF9..U+FFFB (crash string-width due to a bug)
|
|
*/
|
|
export function sanitizeBinaryOutput(str: string): string {
|
|
// All removed characters are single UTF-16 code units, so surrogate pairs are never split.
|
|
return str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\uFFF9-\uFFFB]/g, "");
|
|
}
|
|
|
|
/**
|
|
* Detached child processes must be tracked so they can be killed on parent
|
|
* shutdown signals (SIGHUP/SIGTERM).
|
|
*/
|
|
const trackedDetachedChildPids = new Set<number>();
|
|
|
|
export function trackDetachedChildPid(pid: number): void {
|
|
trackedDetachedChildPids.add(pid);
|
|
}
|
|
|
|
export function untrackDetachedChildPid(pid: number): void {
|
|
trackedDetachedChildPids.delete(pid);
|
|
}
|
|
|
|
export function killTrackedDetachedChildren(): void {
|
|
for (const pid of trackedDetachedChildPids) {
|
|
killProcessTree(pid);
|
|
}
|
|
trackedDetachedChildPids.clear();
|
|
}
|
|
|
|
/**
|
|
* Kill a process and all its children (cross-platform)
|
|
*/
|
|
export function killProcessTree(pid: number): void {
|
|
if (process.platform === "win32") {
|
|
// Use the trusted System32 executable so cleanup does not depend on PATH.
|
|
try {
|
|
const child = spawn(
|
|
join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"),
|
|
["/F", "/T", "/PID", String(pid)],
|
|
{
|
|
stdio: "ignore",
|
|
detached: true,
|
|
windowsHide: true,
|
|
},
|
|
);
|
|
// A failed spawn emits "error" asynchronously; consume it to avoid crashing Node.
|
|
child.once("error", () => {});
|
|
} catch {
|
|
// Ignore errors if taskkill fails.
|
|
}
|
|
} else {
|
|
// Use SIGKILL on Unix/Linux/Mac
|
|
try {
|
|
process.kill(-pid, "SIGKILL");
|
|
} catch {
|
|
// Fallback to killing just the child if process group kill fails
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {
|
|
// Process already dead
|
|
}
|
|
}
|
|
}
|
|
}
|