196 lines
5.1 KiB
TypeScript
196 lines
5.1 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
|
|
import type { Subprocess } from "bun";
|
|
import { $env, filterProcessEnv } from "./env";
|
|
import { $which } from "./which";
|
|
|
|
export interface ShellConfig {
|
|
shell: string;
|
|
args: string[];
|
|
env: Record<string, string>;
|
|
prefix: string | undefined;
|
|
}
|
|
let cachedShellConfig: ShellConfig | null = null;
|
|
|
|
/**
|
|
* Check if a shell binary is executable.
|
|
*/
|
|
export function isExecutable(path: string): boolean {
|
|
try {
|
|
fs.accessSync(path, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the spawn environment (cached).
|
|
*/
|
|
function buildSpawnEnv(shell: string): Record<string, string> {
|
|
const noCI = $env.PI_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
|
|
return {
|
|
...filterProcessEnv(Bun.env),
|
|
SHELL: shell,
|
|
GIT_EDITOR: "true",
|
|
GPG_TTY: "not a tty",
|
|
OMPCODE: "1",
|
|
CLAUDECODE: "1",
|
|
...(noCI ? {} : { CI: "true" }),
|
|
} as Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Get shell args, optionally including login shell flag.
|
|
* Supports PI_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l.
|
|
*/
|
|
function getShellArgs(): string[] {
|
|
const noLogin = $env.PI_BASH_NO_LOGIN || $env.CLAUDE_BASH_NO_LOGIN;
|
|
return noLogin ? ["-c"] : ["-l", "-c"];
|
|
}
|
|
|
|
/**
|
|
* Get shell prefix for wrapping commands (profilers, strace, etc.).
|
|
*/
|
|
function getShellPrefix(): string | undefined {
|
|
return $env.PI_SHELL_PREFIX || $env.CLAUDE_CODE_SHELL_PREFIX;
|
|
}
|
|
|
|
/**
|
|
* Build full shell config from a shell path.
|
|
*/
|
|
function buildConfig(shell: string): ShellConfig {
|
|
return {
|
|
shell,
|
|
args: getShellArgs(),
|
|
env: buildSpawnEnv(shell),
|
|
prefix: getShellPrefix(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a basic shell (bash or sh) as fallback.
|
|
*/
|
|
export function resolveBasicShell(): string | undefined {
|
|
for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
|
|
const resolved = $which(name);
|
|
if (resolved) return resolved;
|
|
}
|
|
|
|
if (process.platform !== "win32") {
|
|
const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
|
|
const candidates = ["bash", "sh"];
|
|
|
|
for (const name of candidates) {
|
|
for (const dir of searchPaths) {
|
|
const fullPath = path.join(dir, name);
|
|
if (fs.existsSync(fullPath)) return fullPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* 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: $SHELL if bash/zsh, then fallback paths
|
|
* 4. Fallback: sh
|
|
*/
|
|
export function getShellConfig(customShellPath?: string): ShellConfig {
|
|
if (cachedShellConfig) {
|
|
return cachedShellConfig;
|
|
}
|
|
|
|
// 1. Check user-specified shell path
|
|
if (customShellPath) {
|
|
if (fs.existsSync(customShellPath)) {
|
|
cachedShellConfig = buildConfig(customShellPath);
|
|
return cachedShellConfig;
|
|
}
|
|
throw new Error(
|
|
`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.omp/agent/settings.json`,
|
|
);
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
// 2. Try Git Bash in known locations
|
|
const paths: string[] = [];
|
|
const programFiles = Bun.env.ProgramFiles;
|
|
if (programFiles) {
|
|
paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
|
}
|
|
const programFilesX86 = Bun.env["ProgramFiles(x86)"];
|
|
if (programFilesX86) {
|
|
paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
|
}
|
|
|
|
for (const path of paths) {
|
|
if (fs.existsSync(path)) {
|
|
cachedShellConfig = buildConfig(path);
|
|
return cachedShellConfig;
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
|
|
const bashOnPath = $which("bash.exe");
|
|
if (bashOnPath) {
|
|
cachedShellConfig = buildConfig(bashOnPath);
|
|
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 ~/.omp/agent/settings.json\n\n` +
|
|
`Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
|
|
);
|
|
}
|
|
|
|
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
|
|
const userShell = Bun.env.SHELL;
|
|
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
|
|
if (isValidShell && isExecutable(userShell)) {
|
|
cachedShellConfig = buildConfig(userShell);
|
|
return cachedShellConfig;
|
|
}
|
|
|
|
// 4. Fallback: use basic shell
|
|
const basicShell = resolveBasicShell();
|
|
if (basicShell) {
|
|
cachedShellConfig = buildConfig(basicShell);
|
|
return cachedShellConfig;
|
|
}
|
|
cachedShellConfig = buildConfig("sh");
|
|
return cachedShellConfig;
|
|
}
|
|
|
|
/**
|
|
* Check if a process is running.
|
|
*/
|
|
export function isPidRunning(pid: number | Subprocess): boolean {
|
|
if (typeof pid !== "number") {
|
|
if (pid.killed) return false;
|
|
if (pid.exitCode !== null) return false;
|
|
return true;
|
|
}
|
|
|
|
return Process.fromPid(pid)?.status() === ProcessStatus.Running;
|
|
}
|
|
|
|
export async function onProcessExit(proc: Subprocess | number, abortSignal?: AbortSignal): Promise<boolean> {
|
|
if (typeof proc !== "number") {
|
|
return proc.exited.then(
|
|
() => true,
|
|
() => true,
|
|
);
|
|
}
|
|
|
|
return (await Process.fromPid(proc)?.waitForExit({ signal: abortSignal })) ?? true;
|
|
}
|