singularity-forge/packages/pi-coding-agent/src/utils/shell.ts
2026-05-05 14:27:03 +02:00

250 lines
7.1 KiB
TypeScript

import { spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { getBinDir, getSettingsPath } from "../config.js";
import { SettingsManager } from "../core/settings-manager.js";
let cachedShellConfig: { shell: string; args: string[] } | null = null;
/**
* Find bash executable on PATH (cross-platform)
*/
function findBashOnPath(): string | null {
if (process.platform === "win32") {
// Windows: Use 'where' and verify file exists (where can return non-existent paths)
try {
const result = spawnSync("where", ["bash.exe"], {
encoding: "utf-8",
timeout: 5000,
});
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", ["bash"], {
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;
}
/**
* Get shell configuration based on platform.
* Resolution order:
* 1. User-specified shellPath in settings.json
* 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(): { shell: string; args: string[] } {
if (cachedShellConfig) {
return cachedShellConfig;
}
const settings = SettingsManager.create();
const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path
if (customShellPath) {
if (existsSync(customShellPath)) {
cachedShellConfig = { shell: customShellPath, args: ["-c"] };
return cachedShellConfig;
}
throw new Error(
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${getSettingsPath()}`,
);
}
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)) {
cachedShellConfig = { shell: path, args: ["-c"] };
return cachedShellConfig;
}
}
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
return cachedShellConfig;
}
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 ${getSettingsPath()}\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")) {
cachedShellConfig = { shell: "/bin/bash", args: ["-c"] };
return cachedShellConfig;
}
const bashOnPath = findBashOnPath();
if (bashOnPath) {
cachedShellConfig = { shell: bashOnPath, args: ["-c"] };
return cachedShellConfig;
}
cachedShellConfig = { shell: "sh", args: ["-c"] };
return cachedShellConfig;
}
/**
* On Windows + Git Bash, rewrite Windows-style NUL redirects to /dev/null.
* Git Bash doesn't recognize NUL as a device name and creates a literal file
* that is undeletable due to NUL being a reserved Windows device name.
* No-op on non-Windows platforms.
*/
export function sanitizeCommand(command: string): string {
if (process.platform !== "win32") return command;
return command.replace(
/(\d*>>?) *\bNUL\b(?=\s|;|\||&|\)|$)/gi,
"$1 /dev/null",
);
}
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,
// Agent-run shells must not open an editor or credential prompt. Commands
// such as `git rebase --continue` should either complete or fail visibly.
GIT_TERMINAL_PROMPT: "0",
GIT_EDITOR: "true",
GIT_SEQUENCE_EDITOR: "true",
GIT_ASKPASS: "",
VISUAL: "true",
EDITOR: "true",
};
}
/**
* Sanitize binary output for display/storage.
* Removes characters that crash string-width or cause display issues:
* - Control characters (except tab, newline, carriage return)
* - Lone surrogates
* - Unicode Format characters (crash string-width due to a bug)
* - Characters with undefined code points
*/
export function sanitizeBinaryOutput(str: string): string {
// Use Array.from to properly iterate over code points (not code units)
// This handles surrogate pairs correctly and catches edge cases where
// codePointAt() might return undefined
return Array.from(str)
.filter((char) => {
// Filter out characters that cause string-width to crash
// This includes:
// - Unicode format characters
// - Lone surrogates (already filtered by Array.from)
// - Control chars except \t \n \r
// - Characters with undefined code points
const code = char.codePointAt(0);
// Skip if code point is undefined (edge case with invalid strings)
if (code === undefined) return false;
// Allow tab, newline, carriage return
if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
// Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
if (code <= 0x1f) return false;
// Filter out Unicode format characters
if (code >= 0xfff9 && code <= 0xfffb) return false;
return true;
})
.join("");
}
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 taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
});
} 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
}
}
}
}