diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 2dcf112ae..c4a03e831 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -1,5 +1,6 @@ export namespace Flag { export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE") + export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export const OPENCODE_CONFIG_DIR = process.env["OPENCODE_CONFIG_DIR"] export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts new file mode 100644 index 000000000..78cd83b66 --- /dev/null +++ b/packages/opencode/src/shell/shell.ts @@ -0,0 +1,195 @@ +import { Flag } from "@/flag/flag" +import { lazy } from "@/util/lazy" +import { spawn as nodeSpawn } from "child_process" +import path from "path" + +export namespace Shell { + const BLACKLIST = new Set(["fish", "nu"]) + const SIGKILL_TIMEOUT_MS = 200 + + function fallback() { + if (process.platform === "win32") { + if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH + const git = Bun.which("git") + if (git) { + // git.exe is typically at: C:\Program Files\Git\cmd\git.exe + // bash.exe is at: C:\Program Files\Git\bin\bash.exe + const bash = path.join(git, "..", "..", "bin", "bash.exe") + if (Bun.file(bash).size) return bash + } + return process.env.COMSPEC || "cmd.exe" + } + if (process.platform === "darwin") return "/bin/zsh" + const bash = Bun.which("bash") + if (bash) return bash + return "/bin/sh" + } + + export const preferred = lazy(() => { + const s = process.env.SHELL + if (s) return s + return fallback() + }) + + export const acceptable = lazy(() => { + const s = process.env.SHELL + if (s && !BLACKLIST.has(path.basename(s))) return s + return fallback() + }) + + export interface SpawnOptions { + command: string + cwd: string + shell?: string + source?: boolean + env?: Record + timeout?: number + abort?: AbortSignal + onData?: (chunk: Buffer) => void + } + + export interface SpawnResult { + output: string + exitCode: number | null + timedOut: boolean + aborted: boolean + } + + function args(shell: string, command: string, source?: boolean): string[] { + const name = path.basename(shell).toLowerCase() + + if (name === "nu") return ["-c", command] + if (name === "fish") return ["-c", command] + + if (name === "zsh") { + if (!source) return ["-c", command] + return [ + "-c", + "-l", + ` + [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true + [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true + ${command} + `.trim(), + ] + } + + if (name === "bash" || name === "bash.exe") { + if (!source) return ["-c", command] + return [ + "-c", + "-l", + ` + [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true + ${command} + `.trim(), + ] + } + + if (name === "cmd.exe") return ["/c", command] + if (name === "powershell.exe") return ["-NoProfile", "-Command", command] + + // fallback + if (!source) return ["-c", command] + return ["-c", "-l", command] + } + + export async function spawn(options: SpawnOptions): Promise { + const shell = options.shell ?? acceptable() + const proc = nodeSpawn(shell, args(shell, options.command, options.source), { + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + + let output = "" + let timedOut = false + let aborted = false + let exited = false + + const append = (chunk: Buffer) => { + output += chunk.toString() + options.onData?.(chunk) + } + + proc.stdout?.on("data", append) + proc.stderr?.on("data", append) + + const killTree = async () => { + const pid = proc.pid + if (!pid || exited) return + + if (process.platform === "win32") { + await new Promise((resolve) => { + const killer = nodeSpawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" }) + killer.once("exit", resolve) + killer.once("error", resolve) + }) + return + } + + try { + process.kill(-pid, "SIGTERM") + await Bun.sleep(SIGKILL_TIMEOUT_MS) + if (!exited) { + process.kill(-pid, "SIGKILL") + } + } catch { + proc.kill("SIGTERM") + await Bun.sleep(SIGKILL_TIMEOUT_MS) + if (!exited) { + proc.kill("SIGKILL") + } + } + } + + if (options.abort?.aborted) { + aborted = true + await killTree() + } + + const abortHandler = () => { + aborted = true + void killTree() + } + + options.abort?.addEventListener("abort", abortHandler, { once: true }) + + const timeoutTimer = options.timeout + ? setTimeout(() => { + timedOut = true + void killTree() + }, options.timeout) + : undefined + + await new Promise((resolve, reject) => { + const cleanup = () => { + if (timeoutTimer) clearTimeout(timeoutTimer) + options.abort?.removeEventListener("abort", abortHandler) + } + + proc.once("exit", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) + }) + + return { + output, + exitCode: proc.exitCode, + timedOut, + aborted, + } + } +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0c099fe80..dcfaa6f0d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -14,7 +14,7 @@ import { Permission } from "@/permission" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import path from "path" -import { iife } from "@/util/iife" +import { Shell } from "@/shell/shell" const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -53,32 +53,7 @@ const parser = lazy(async () => { // TODO: we may wanna rename this tool so it works better on other shells export const BashTool = Tool.define("bash", async () => { - const shell = iife(() => { - const s = process.env.SHELL - if (s) { - const basename = path.basename(s) - if (!new Set(["fish", "nu"]).has(basename)) { - return s - } - } - - if (process.platform === "darwin") { - return "/bin/zsh" - } - - if (process.platform === "win32") { - // Let Bun / Node pick COMSPEC (usually cmd.exe) - // or explicitly: - return process.env.COMSPEC || true - } - - const bash = Bun.which("bash") - if (bash) { - return bash - } - - return true - }) + const shell = Shell.acceptable() log.info("bash tool using shell", { shell }) return {