import { Flag } from "@opencode-ai/core/flag/flag" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util/filesystem" import { which } from "@/util/which" import path from "path" import { spawn, type ChildProcess } from "child_process" import { setTimeout as sleep } from "node:timers/promises" const SIGKILL_TIMEOUT_MS = 200 const META: Record = { bash: { login: true, posix: true }, dash: { login: true, posix: true }, fish: { deny: true, login: true }, ksh: { login: true, posix: true }, nu: { deny: true }, powershell: { ps: true }, pwsh: { ps: true }, sh: { login: true, posix: true }, zsh: { login: true, posix: true }, } export type Item = { path: string name: string acceptable: boolean } export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise { const pid = proc.pid if (!pid || opts?.exited?.()) return if (process.platform === "win32") { await new Promise((resolve) => { const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore", windowsHide: true, }) killer.once("exit", () => resolve()) killer.once("error", () => resolve()) }) return } try { process.kill(-pid, "SIGTERM") await sleep(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { process.kill(-pid, "SIGKILL") } } catch (_e) { proc.kill("SIGTERM") await sleep(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { proc.kill("SIGKILL") } } } function full(file: string) { if (process.platform !== "win32") return file const shell = Filesystem.windowsPath(file) if (path.win32.dirname(shell) !== ".") { if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell return shell } if (name(shell) === "bash") return gitbash() || which(shell) || shell return which(shell) || shell } function meta(file: string) { return META[name(file)] } function ok(file: string) { return meta(file)?.deny !== true } function rooted(file: string) { return path.isAbsolute(Filesystem.windowsPath(file)) } function resolve(file: string) { const shell = full(file) if (rooted(shell)) { if (Filesystem.stat(shell)?.isFile()) return shell return } return which(shell) ?? undefined } function win() { return Array.from( new Set( [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] .filter((item): item is string => Boolean(item)) .map(full), ), ) } async function unix() { const text = await Filesystem.readText("/etc/shells").catch(() => "") if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#")))) return ["/bin/bash", "/bin/zsh", "/bin/sh"] } function select(file: string | undefined, opts?: { acceptable?: boolean }) { if (file && (!opts?.acceptable || ok(file))) { const shell = resolve(file) if (shell) return shell } if (process.platform === "win32") return win()[0]! return fallback() } export function gitbash() { if (process.platform !== "win32") return if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH const git = which("git") if (!git) return const file = path.join(git, "..", "..", "bin", "bash.exe") if (Filesystem.stat(file)?.size) return file } function fallback() { if (process.platform === "darwin") return "/bin/zsh" const bash = which("bash") if (bash) return bash return "/bin/sh" } export function name(file: string) { if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase() return path.basename(file).toLowerCase() } export function login(file: string) { return meta(file)?.login === true } export function posix(file: string) { return meta(file)?.posix === true } export function ps(file: string) { return meta(file)?.ps === true } function info(file: string): Item { const item = full(file) const n = name(item) return { path: item, name: resolve(n) ? n : item, acceptable: ok(item), } } export function args(file: string, command: string, cwd: string) { const n = name(file) if (n === "nu" || n === "fish") return ["-c", command] if (n === "zsh") { return [ "-l", "-c", ` [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true cd -- "$1" eval ${JSON.stringify(command)} `, "opencode", cwd, ] } if (n === "bash") { return [ "-l", "-c", ` shopt -s expand_aliases [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true cd -- "$1" eval ${JSON.stringify(command)} `, "opencode", cwd, ] } if (n === "cmd") return ["/c", command] if (ps(file)) return ["-NoProfile", "-Command", command] return ["-c", command] } const defaultPreferred = lazy(() => select(process.env.SHELL)) const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true })) export function preferred(configShell?: string) { if (configShell) return select(configShell) return defaultPreferred() } preferred.reset = () => defaultPreferred.reset() export function acceptable(configShell?: string) { if (configShell) return select(configShell, { acceptable: true }) return defaultAcceptable() } acceptable.reset = () => defaultAcceptable.reset() export async function list(): Promise { const shells = process.platform === "win32" ? win() : await unix() return shells.filter((s) => resolve(s)).map(info) } export * as Shell from "./shell"