From 5d68b1b1486c205c16fd4917cc87a8e6779fdf9a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Mar 2026 00:54:11 -0500 Subject: [PATCH 1/5] refactor(opencode): replace Bun shell in core flows --- packages/opencode/src/cli/cmd/pr.ts | 47 ++- .../src/cli/cmd/tui/util/clipboard.ts | 47 ++- packages/opencode/src/cli/cmd/uninstall.ts | 19 +- packages/opencode/src/file/index.ts | 38 +-- packages/opencode/src/file/ripgrep.ts | 14 +- packages/opencode/src/file/watcher.ts | 13 +- packages/opencode/src/installation/index.ts | 102 +++--- packages/opencode/src/project/vcs.ts | 16 +- packages/opencode/src/snapshot/index.ts | 293 ++++++++++++------ packages/opencode/src/storage/storage.ts | 21 +- packages/opencode/src/tool/bash.ts | 9 +- packages/opencode/src/util/archive.ts | 9 +- packages/opencode/src/util/process.ts | 16 + packages/opencode/src/worktree/index.ts | 69 +++-- 14 files changed, 456 insertions(+), 257 deletions(-) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index d6176572002..ea61354741b 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,7 +1,8 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Instance } from "@/project/instance" -import { $ } from "bun" +import { Process } from "@/util/process" +import { git } from "@/util/git" export const PrCommand = cmd({ command: "pr ", @@ -27,21 +28,35 @@ export const PrCommand = cmd({ UI.println(`Fetching and checking out PR #${prNumber}...`) // Use gh pr checkout with custom branch name - const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow() + const result = await Process.run( + ["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], + { + nothrow: true, + }, + ) - if (result.exitCode !== 0) { + if (result.code !== 0) { UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) process.exit(1) } // Fetch PR info for fork handling and session link detection - const prInfoResult = - await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow() + const prInfoResult = await Process.text( + [ + "gh", + "pr", + "view", + `${prNumber}`, + "--json", + "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", + ], + { nothrow: true }, + ) let sessionId: string | undefined - if (prInfoResult.exitCode === 0) { - const prInfoText = prInfoResult.text() + if (prInfoResult.code === 0) { + const prInfoText = prInfoResult.text if (prInfoText.trim()) { const prInfo = JSON.parse(prInfoText) @@ -52,15 +67,19 @@ export const PrCommand = cmd({ const remoteName = forkOwner // Check if remote already exists - const remotes = (await $`git remote`.nothrow().text()).trim() + const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow() + await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + cwd: Instance.worktree, + }) UI.println(`Added fork remote: ${remoteName}`) } // Set upstream to the fork so pushes go there const headRefName = prInfo.headRefName - await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow() + await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { + cwd: Instance.worktree, + }) } // Check for opencode session link in PR body @@ -71,9 +90,11 @@ export const PrCommand = cmd({ UI.println(`Found opencode session: ${sessionUrl}`) UI.println(`Importing session...`) - const importResult = await $`opencode import ${sessionUrl}`.nothrow() - if (importResult.exitCode === 0) { - const importOutput = importResult.text().trim() + const importResult = await Process.text(["opencode", "import", sessionUrl], { + nothrow: true, + }) + if (importResult.code === 0) { + const importOutput = importResult.text.trim() // Extract session ID from the output (format: "Imported session: ") const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/) if (sessionIdMatch) { diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 412ec654ff9..85e13d31339 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -1,9 +1,9 @@ -import { $ } from "bun" import { platform, release } from "os" import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +import fs from "fs/promises" import { Filesystem } from "../../../../util/filesystem" import { Process } from "../../../../util/process" import { which } from "../../../../util/which" @@ -34,23 +34,38 @@ export namespace Clipboard { if (os === "darwin") { const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") try { - await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'` - .nothrow() - .quiet() + await Process.run( + [ + "osascript", + "-e", + 'set imageData to the clipboard as "PNGf"', + "-e", + `set fileRef to open for access POSIX file "${tmpfile}" with write permission`, + "-e", + "set eof fileRef to 0", + "-e", + "write imageData to fileRef", + "-e", + "close access fileRef", + ], + { nothrow: true }, + ) const buffer = await Filesystem.readBytes(tmpfile) return { data: buffer.toString("base64"), mime: "image/png" } } catch { } finally { - await $`rm -f "${tmpfile}"`.nothrow().quiet() + await fs.rm(tmpfile, { force: true }).catch(() => {}) } } if (os === "win32" || release().includes("WSL")) { const script = "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" - const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text() - if (base64) { - const imageBuffer = Buffer.from(base64.trim(), "base64") + const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], { + nothrow: true, + }) + if (base64.text) { + const imageBuffer = Buffer.from(base64.text.trim(), "base64") if (imageBuffer.length > 0) { return { data: imageBuffer.toString("base64"), mime: "image/png" } } @@ -58,13 +73,15 @@ export namespace Clipboard { } if (os === "linux") { - const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer() - if (wayland && wayland.byteLength > 0) { - return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" } + const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) + if (wayland.stdout.byteLength > 0) { + return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } } - const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer() - if (x11 && x11.byteLength > 0) { - return { data: Buffer.from(x11).toString("base64"), mime: "image/png" } + const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { + nothrow: true, + }) + if (x11.stdout.byteLength > 0) { + return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } } } @@ -81,7 +98,7 @@ export namespace Clipboard { console.log("clipboard: using osascript") return async (text: string) => { const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet() + await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true }) } } diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 3d8e7e3f75e..de41f32a0d1 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -3,11 +3,11 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { Installation } from "../../installation" import { Global } from "../../global" -import { $ } from "bun" import fs from "fs/promises" import path from "path" import os from "os" import { Filesystem } from "../../util/filesystem" +import { Process } from "../../util/process" interface UninstallArgs { keepConfig: boolean @@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar const cmd = cmds[method] if (cmd) { spinner.start(`Running ${cmd.join(" ")}...`) - const result = - method === "choco" - ? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow() - : await $`${cmd}`.quiet().nothrow() - if (result.exitCode !== 0) { - spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1) - if ( - method === "choco" && - result.stdout.toString("utf8").includes("not running from an elevated command shell") - ) { + const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, { + nothrow: true, + }) + if (result.code !== 0) { + spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1) + const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}` + if (method === "choco" && text.includes("not running from an elevated command shell")) { prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`) } else { prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index b7daddc5fb8..d4918da8e74 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,6 +1,5 @@ import { BusEvent } from "@/bus/bus-event" import z from "zod" -import { $ } from "bun" import { formatPatch, structuredPatch } from "diff" import path from "path" import fs from "fs" @@ -11,6 +10,7 @@ import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" import { Global } from "../global" +import { git } from "@/util/git" export namespace File { const log = Log.create({ service: "file" }) @@ -418,11 +418,11 @@ export namespace File { const project = Instance.project if (project.vcs !== "git") return [] - const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const diffOutput = ( + await git(["-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + cwd: Instance.directory, + }) + ).text() const changedFiles: Info[] = [] @@ -439,11 +439,11 @@ export namespace File { } } - const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const untrackedOutput = ( + await git(["-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"], { + cwd: Instance.directory, + }) + ).text() if (untrackedOutput.trim()) { const untrackedFiles = untrackedOutput.trim().split("\n") @@ -464,11 +464,11 @@ export namespace File { } // Get deleted files - const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD` - .cwd(Instance.directory) - .quiet() - .nothrow() - .text() + const deletedOutput = ( + await git(["-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"], { + cwd: Instance.directory, + }) + ).text() if (deletedOutput.trim()) { const deletedFiles = deletedOutput.trim().split("\n") @@ -539,10 +539,10 @@ export namespace File { const content = (await Filesystem.readText(full).catch(() => "")).trim() if (project.vcs === "git") { - let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() - if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text() + let diff = (await git(["diff", "--", file], { cwd: Instance.directory })).text() + if (!diff.trim()) diff = (await git(["diff", "--staged", "--", file], { cwd: Instance.directory })).text() if (diff.trim()) { - const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text() + const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 09fef453c9a..b1068f5d8e5 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -5,7 +5,7 @@ import fs from "fs/promises" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { lazy } from "../util/lazy" -import { $ } from "bun" + import { Filesystem } from "../util/filesystem" import { Process } from "../util/process" import { which } from "../util/which" @@ -338,7 +338,7 @@ export namespace Ripgrep { limit?: number follow?: boolean }) { - const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"] if (input.follow) args.push("--follow") if (input.glob) { @@ -354,14 +354,16 @@ export namespace Ripgrep { args.push("--") args.push(input.pattern) - const command = args.join(" ") - const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() - if (result.exitCode !== 0) { + const result = await Process.text(args, { + cwd: input.cwd, + nothrow: true, + }) + if (result.code !== 0) { return [] } // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = result.text().trim().split(/\r?\n/).filter(Boolean) + const lines = result.text.trim().split(/\r?\n/).filter(Boolean) // Parse JSON lines from ripgrep output return lines diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 626a746c832..537f5264631 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper" import { lazy } from "@/util/lazy" import { withTimeout } from "@/util/timeout" import type ParcelWatcher from "@parcel/watcher" -import { $ } from "bun" import { Flag } from "@/flag/flag" import { readdir } from "fs/promises" +import { git } from "@/util/git" const SUBSCRIBE_TIMEOUT_MS = 10_000 @@ -88,13 +88,10 @@ export namespace FileWatcher { } if (Instance.project.vcs === "git") { - const vcsDir = await $`git rev-parse --git-dir` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => path.resolve(Instance.worktree, x.trim())) - .catch(() => undefined) + const result = await git(["rev-parse", "--git-dir"], { + cwd: Instance.worktree, + }) + const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { const gitDirContents = await readdir(vcsDir).catch(() => []) const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD") diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 47278bd5628..90c64226dbb 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,11 +1,12 @@ import { BusEvent } from "@/bus/bus-event" import path from "path" -import { $ } from "bun" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" import { iife } from "@/util/iife" import { Flag } from "../flag/flag" +import { Process } from "@/util/process" +import { buffer } from "node:stream/consumers" declare global { const OPENCODE_VERSION: string @@ -15,6 +16,38 @@ declare global { export namespace Installation { const log = Log.create({ service: "installation" }) + async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { + return Process.text(cmd, { + cwd: opts.cwd, + env: opts.env, + nothrow: true, + }).then((x) => x.text) + } + + async function upgradeCurl(target: string) { + const body = await fetch("https://opencode.ai/install").then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.text() + }) + const proc = Process.spawn(["bash"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + VERSION: target, + }, + }) + if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available") + proc.stdin.end(body) + const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) + return { + code, + stdout, + stderr, + } + } + export type Method = Awaited> export const Event = { @@ -65,31 +98,31 @@ export namespace Installation { const checks = [ { name: "npm" as const, - command: () => $`npm list -g --depth=0`.throws(false).quiet().text(), + command: () => text(["npm", "list", "-g", "--depth=0"]), }, { name: "yarn" as const, - command: () => $`yarn global list`.throws(false).quiet().text(), + command: () => text(["yarn", "global", "list"]), }, { name: "pnpm" as const, - command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(), + command: () => text(["pnpm", "list", "-g", "--depth=0"]), }, { name: "bun" as const, - command: () => $`bun pm ls -g`.throws(false).quiet().text(), + command: () => text(["bun", "pm", "ls", "-g"]), }, { name: "brew" as const, - command: () => $`brew list --formula opencode`.throws(false).quiet().text(), + command: () => text(["brew", "list", "--formula", "opencode"]), }, { name: "scoop" as const, - command: () => $`scoop list opencode`.throws(false).quiet().text(), + command: () => text(["scoop", "list", "opencode"]), }, { name: "choco" as const, - command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(), + command: () => text(["choco", "list", "--limit-output", "opencode"]), }, ] @@ -121,61 +154,56 @@ export namespace Installation { ) async function getBrewFormula() { - const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text() + const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" - const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text() + const coreFormula = await text(["brew", "list", "--formula", "opencode"]) if (coreFormula.includes("opencode")) return "opencode" return "opencode" } export async function upgrade(method: Method, target: string) { - let cmd + let result switch (method) { case "curl": - cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({ - ...process.env, - VERSION: target, - }) + result = await upgradeCurl(target) break case "npm": - cmd = $`npm install -g opencode-ai@${target}` + result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) break case "pnpm": - cmd = $`pnpm install -g opencode-ai@${target}` + result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) break case "bun": - cmd = $`bun install -g opencode-ai@${target}` + result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) break case "brew": { const formula = await getBrewFormula() + const env = { + HOMEBREW_NO_AUTO_UPDATE: "1", + ...process.env, + } if (formula.includes("/")) { - cmd = - $`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env( - { - HOMEBREW_NO_AUTO_UPDATE: "1", - ...process.env, - }, - ) + await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) + const repo = (await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })).text.trim() + if (repo) await Process.run(["git", "pull", "--ff-only"], { cwd: repo, env, nothrow: true }) + result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true }) break } - cmd = $`brew upgrade ${formula}`.env({ - HOMEBREW_NO_AUTO_UPDATE: "1", - ...process.env, - }) + result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true }) break } case "choco": - cmd = $`echo Y | choco upgrade opencode --version=${target}` + result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) break case "scoop": - cmd = $`scoop install opencode@${target}` + result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true }) break default: throw new Error(`Unknown method: ${method}`) } - const result = await cmd.quiet().throws(false) - if (result.exitCode !== 0) { - const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8") + if (!result || result.code !== 0) { + const stderr = + method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || "" throw new UpgradeFailedError({ stderr: stderr, }) @@ -186,7 +214,7 @@ export namespace Installation { stdout: result.stdout.toString(), stderr: result.stderr.toString(), }) - await $`${process.execPath} --version`.nothrow().quiet().text() + await Process.text([process.execPath, "--version"], { nothrow: true }) } export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" @@ -199,7 +227,7 @@ export namespace Installation { if (detectedMethod === "brew") { const formula = await getBrewFormula() if (formula.includes("/")) { - const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text() + const infoJson = await text(["brew", "info", "--json=v2", formula]) const info = JSON.parse(infoJson) const version = info.formulae?.[0]?.versions?.stable if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) @@ -215,7 +243,7 @@ export namespace Installation { if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { const registry = await iife(async () => { - const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() + const r = (await text(["npm", "config", "get", "registry"])).trim() const reg = r || "https://registry.npmjs.org" return reg.endsWith("/") ? reg.slice(0, -1) : reg }) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e434b5f8c3a..34d59054314 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,11 +1,11 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { $ } from "bun" import path from "path" import z from "zod" import { Log } from "@/util/log" import { Instance } from "./instance" import { FileWatcher } from "@/file/watcher" +import { git } from "@/util/git" const log = Log.create({ service: "vcs" }) @@ -29,13 +29,13 @@ export namespace Vcs { export type Info = z.infer async function currentBranch() { - return $`git rev-parse --abbrev-ref HEAD` - .quiet() - .nothrow() - .cwd(Instance.worktree) - .text() - .then((x) => x.trim()) - .catch(() => undefined) + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: Instance.worktree, + }) + if (result.exitCode !== 0) return + const text = result.text().trim() + if (!text) return + return text } const state = Instance.state( diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 1acbdba092e..848f2694e01 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,4 +1,3 @@ -import { $ } from "bun" import path from "path" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" @@ -9,12 +8,17 @@ import z from "zod" import { Config } from "../config/config" import { Instance } from "../project/instance" import { Scheduler } from "../scheduler" +import { Process } from "@/util/process" export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" + function args(git: string, cmd: string[]) { + return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd] + } + export function init() { Scheduler.register({ id: "snapshot.cleanup", @@ -34,13 +38,13 @@ export namespace Snapshot { .then(() => true) .catch(() => false) if (!exists) return - const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` - .quiet() - .cwd(Instance.directory) - .nothrow() - if (result.exitCode !== 0) { + const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], { + cwd: Instance.directory, + nothrow: true, + }) + if (result.code !== 0) { log.warn("cleanup failed", { - exitCode: result.exitCode, + exitCode: result.code, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) @@ -55,27 +59,27 @@ export namespace Snapshot { if (cfg.snapshot === false) return const git = gitdir() if (await fs.mkdir(git, { recursive: true })) { - await $`git init` - .env({ + await Process.run(["git", "init"], { + env: { ...process.env, GIT_DIR: git, GIT_WORK_TREE: Instance.worktree, - }) - .quiet() - .nothrow() + }, + nothrow: true, + }) + // Configure git to not convert line endings on Windows - await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow() - await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow() - await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow() - await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow() + await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true }) + await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true }) + await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true }) + await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true }) log.info("initialized") } await add(git) - const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` - .quiet() - .cwd(Instance.directory) - .nothrow() - .text() + const hash = await Process.text(["git", ...args(git, ["write-tree"])], { + cwd: Instance.directory, + nothrow: true, + }).then((x) => x.text) log.info("tracking", { hash, cwd: Instance.directory, git }) return hash.trim() } @@ -89,19 +93,32 @@ export namespace Snapshot { export async function patch(hash: string): Promise { const git = gitdir() await add(git) - const result = - await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` - .quiet() - .cwd(Instance.directory) - .nothrow() + const result = await Process.text( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", + ...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]), + ], + { + cwd: Instance.directory, + nothrow: true, + }, + ) // If git diff fails, return empty patch - if (result.exitCode !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.exitCode }) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) return { hash, files: [] } } - const files = result.text() + const files = result.text return { hash, files: files @@ -116,20 +133,37 @@ export namespace Snapshot { export async function restore(snapshot: string) { log.info("restore", { commit: snapshot }) const git = gitdir() - const result = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` - .quiet() - .cwd(Instance.worktree) - .nothrow() - - if (result.exitCode !== 0) { + const result = await Process.run( + ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])], + { + cwd: Instance.worktree, + nothrow: true, + }, + ) + if (result.code === 0) { + const checkout = await Process.run( + ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])], + { + cwd: Instance.worktree, + nothrow: true, + }, + ) + if (checkout.code === 0) return log.error("failed to restore snapshot", { snapshot, - exitCode: result.exitCode, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), + exitCode: checkout.code, + stderr: checkout.stderr.toString(), + stdout: checkout.stdout.toString(), }) + return } + + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr.toString(), + stdout: result.stdout.toString(), + }) } export async function revert(patches: Patch[]) { @@ -139,19 +173,37 @@ export namespace Snapshot { for (const file of item.files) { if (files.has(file)) continue log.info("reverting", { file, hash: item.hash }) - const result = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}` - .quiet() - .cwd(Instance.worktree) - .nothrow() - if (result.exitCode !== 0) { + const result = await Process.run( + [ + "git", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + ...args(git, ["checkout", item.hash, "--", file]), + ], + { + cwd: Instance.worktree, + nothrow: true, + }, + ) + if (result.code !== 0) { const relativePath = path.relative(Instance.worktree, file) - const checkTree = - await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}` - .quiet() - .cwd(Instance.worktree) - .nothrow() - if (checkTree.exitCode === 0 && checkTree.text().trim()) { + const checkTree = await Process.text( + [ + "git", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + ...args(git, ["ls-tree", item.hash, "--", relativePath]), + ], + { + cwd: Instance.worktree, + nothrow: true, + }, + ) + if (checkTree.code === 0 && checkTree.text.trim()) { log.info("file existed in snapshot but checkout failed, keeping", { file, }) @@ -168,23 +220,36 @@ export namespace Snapshot { export async function diff(hash: string) { const git = gitdir() await add(git) - const result = - await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` - .quiet() - .cwd(Instance.worktree) - .nothrow() + const result = await Process.text( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", + ...args(git, ["diff", "--no-ext-diff", hash, "--", "."]), + ], + { + cwd: Instance.worktree, + nothrow: true, + }, + ) - if (result.exitCode !== 0) { + if (result.code !== 0) { log.warn("failed to get diff", { hash, - exitCode: result.exitCode, + exitCode: result.code, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) return "" } - return result.text().trim() + return result.text.trim() } export const FileDiff = z @@ -205,12 +270,24 @@ export namespace Snapshot { const result: FileDiff[] = [] const status = new Map() - const statuses = - await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` - .quiet() - .cwd(Instance.directory) - .nothrow() - .text() + const statuses = await Process.text( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", + ...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), + ], + { + cwd: Instance.directory, + nothrow: true, + }, + ).then((x) => x.text) for (const line of statuses.trim().split("\n")) { if (!line) continue @@ -220,26 +297,57 @@ export namespace Snapshot { status.set(file, kind) } - for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` - .quiet() - .cwd(Instance.directory) - .nothrow() - .lines()) { + for (const line of await Process.lines( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + "-c", + "core.quotepath=false", + ...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]), + ], + { + cwd: Instance.directory, + nothrow: true, + }, + )) { if (!line) continue const [additions, deletions, file] = line.split("\t") const isBinaryFile = additions === "-" && deletions === "-" const before = isBinaryFile ? "" - : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` - .quiet() - .nothrow() - .text() + : await Process.text( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + ...args(git, ["show", `${from}:${file}`]), + ], + { nothrow: true }, + ).then((x) => x.text) const after = isBinaryFile ? "" - : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` - .quiet() - .nothrow() - .text() + : await Process.text( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + ...args(git, ["show", `${to}:${file}`]), + ], + { nothrow: true }, + ).then((x) => x.text) const added = isBinaryFile ? 0 : parseInt(additions) const deleted = isBinaryFile ? 0 : parseInt(deletions) result.push({ @@ -261,10 +369,22 @@ export namespace Snapshot { async function add(git: string) { await syncExclude(git) - await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .` - .quiet() - .cwd(Instance.directory) - .nothrow() + await Process.run( + [ + "git", + "-c", + "core.autocrlf=false", + "-c", + "core.longpaths=true", + "-c", + "core.symlinks=true", + ...args(git, ["add", "."]), + ], + { + cwd: Instance.directory, + nothrow: true, + }, + ) } async function syncExclude(git: string) { @@ -281,11 +401,10 @@ export namespace Snapshot { } async function excludes() { - const file = await $`git rev-parse --path-format=absolute --git-path info/exclude` - .quiet() - .cwd(Instance.worktree) - .nothrow() - .text() + const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: Instance.worktree, + nothrow: true, + }).then((x) => x.text) if (!file.trim()) return const exists = await fs .stat(file.trim()) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index a78ff04f43d..a78607cdfd5 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,10 +5,10 @@ import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { lazy } from "../util/lazy" import { Lock } from "../util/lock" -import { $ } from "bun" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import { Glob } from "../util/glob" +import { git } from "@/util/git" export namespace Storage { const log = Log.create({ service: "storage" }) @@ -49,18 +49,15 @@ export namespace Storage { } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue - const [id] = await $`git rev-list --max-parents=0 --all` - .quiet() - .nothrow() - .cwd(worktree) + const result = await git(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }) + const [id] = result .text() - .then((x) => - x - .split("\n") - .filter(Boolean) - .map((x) => x.trim()) - .toSorted(), - ) + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .toSorted() if (!id) continue projectID = id diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0751f789b7d..3be2734863a 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -7,8 +7,8 @@ import { Log } from "../util/log" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" +import fs from "fs/promises" -import { $ } from "bun" import { Filesystem } from "@/util/filesystem" import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" @@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => { if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) { for (const arg of command.slice(1)) { if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue - const resolved = await $`realpath ${arg}` - .cwd(cwd) - .quiet() - .nothrow() - .text() - .then((x) => x.trim()) + const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "") log.info("resolved path", { arg, resolved }) if (resolved) { const normalized = diff --git a/packages/opencode/src/util/archive.ts b/packages/opencode/src/util/archive.ts index 34a1738a8c0..f65ceba5471 100644 --- a/packages/opencode/src/util/archive.ts +++ b/packages/opencode/src/util/archive.ts @@ -1,5 +1,5 @@ -import { $ } from "bun" import path from "path" +import { Process } from "./process" export namespace Archive { export async function extractZip(zipPath: string, destDir: string) { @@ -8,9 +8,10 @@ export namespace Archive { const winDestDir = path.resolve(destDir) // $global:ProgressPreference suppresses PowerShell's blue progress bar popup const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force` - await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet() - } else { - await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet() + await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd]) + return } + + await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir]) } } diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 71f001a86a1..1549be0b6cd 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -25,6 +25,10 @@ export namespace Process { stderr: Buffer } + export interface TextResult extends Result { + text: string + } + export class RunFailedError extends Error { readonly cmd: string[] readonly code: number @@ -123,4 +127,16 @@ export namespace Process { if (out.code === 0 || opts.nothrow) return out throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) } + + export async function text(cmd: string[], opts: RunOptions = {}): Promise { + const out = await run(cmd, opts) + return { + ...out, + text: out.stdout.toString(), + } + } + + export async function lines(cmd: string[], opts: RunOptions = {}): Promise { + return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean) + } } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2267322494b..a8b0b736049 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,4 +1,3 @@ -import { $ } from "bun" import fs from "fs/promises" import path from "path" import z from "zod" @@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" import { fn } from "../util/fn" import { Log } from "../util/log" +import { Process } from "../util/process" +import { git } from "../util/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" @@ -248,14 +249,14 @@ export namespace Worktree { } async function sweep(root: string) { - const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root) + const first = await git(["clean", "-ffdx"], { cwd: root }) if (first.exitCode === 0) return first const entries = failed(first) if (!entries.length) return first await prune(root, entries) - return $`git clean -ffdx`.quiet().nothrow().cwd(root) + return git(["clean", "-ffdx"], { cwd: root }) } async function canonical(input: string) { @@ -274,7 +275,9 @@ export namespace Worktree { if (await exists(directory)) continue const ref = `refs/heads/${branch}` - const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree) + const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], { + cwd: Instance.worktree, + }) if (branchCheck.exitCode === 0) continue return Info.parse({ name, branch, directory }) @@ -285,9 +288,9 @@ export namespace Worktree { async function runStartCommand(directory: string, cmd: string) { if (process.platform === "win32") { - return $`cmd /c ${cmd}`.nothrow().cwd(directory) + return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true }) } - return $`bash -lc ${cmd}`.nothrow().cwd(directory) + return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true }) } type StartKind = "project" | "worktree" @@ -297,7 +300,7 @@ export namespace Worktree { if (!text) return true const ran = await runStartCommand(directory, text) - if (ran.exitCode === 0) return true + if (ran.code === 0) return true log.error("worktree start command failed", { kind, @@ -344,10 +347,9 @@ export namespace Worktree { } export async function createFromInfo(info: Info, startCommand?: string) { - const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}` - .quiet() - .nothrow() - .cwd(Instance.worktree) + const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { + cwd: Instance.worktree, + }) if (created.exitCode !== 0) { throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" }) } @@ -359,7 +361,7 @@ export namespace Worktree { return () => { const start = async () => { - const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory) + const populated = await git(["reset", "--hard"], { cwd: info.directory }) if (populated.exitCode !== 0) { const message = errorText(populated) || "Failed to populate worktree" log.error("worktree checkout failed", { directory: info.directory, message }) @@ -474,7 +476,7 @@ export namespace Worktree { throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) }) - const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -489,9 +491,11 @@ export namespace Worktree { return true } - const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree) + const removed = await git(["worktree", "remove", "--force", entry.path], { + cwd: Instance.worktree, + }) if (removed.exitCode !== 0) { - const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) + const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (next.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(removed) || errorText(next) || "Failed to remove git worktree", @@ -508,7 +512,7 @@ export namespace Worktree { const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { - const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree) + const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree }) if (deleted.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" }) } @@ -528,7 +532,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) } - const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) + const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (list.exitCode !== 0) { throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) } @@ -561,7 +565,7 @@ export namespace Worktree { throw new ResetFailedError({ message: "Worktree not found" }) } - const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree) + const remoteList = await git(["remote"], { cwd: Instance.worktree }) if (remoteList.exitCode !== 0) { throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) } @@ -580,18 +584,19 @@ export namespace Worktree { : "" const remoteHead = remote - ? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree) + ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) : { exitCode: 1, stdout: undefined, stderr: undefined } const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" - const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree) - const masterCheck = await $`git show-ref --verify --quiet refs/heads/master` - .quiet() - .nothrow() - .cwd(Instance.worktree) + const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { + cwd: Instance.worktree, + }) + const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { + cwd: Instance.worktree, + }) const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch @@ -600,7 +605,7 @@ export namespace Worktree { } if (remoteBranch) { - const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree) + const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) if (fetch.exitCode !== 0) { throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) } @@ -612,7 +617,7 @@ export namespace Worktree { const worktreePath = entry.path - const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath) + const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) if (resetToTarget.exitCode !== 0) { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } @@ -622,22 +627,26 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } - const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath) + const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) if (update.exitCode !== 0) { throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) } - const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath) + const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { + cwd: worktreePath, + }) if (subReset.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) } - const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath) + const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { + cwd: worktreePath, + }) if (subClean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } - const status = await $`git status --porcelain=v1`.quiet().nothrow().cwd(worktreePath) + const status = await git(["status", "--porcelain=v1"], { cwd: worktreePath }) if (status.exitCode !== 0) { throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) } From 1454dd1dc1867af9177b41a2218c651afb5eb213 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Mar 2026 01:09:00 -0500 Subject: [PATCH 2/5] refactor(opencode): remove remaining Bun shell usage --- packages/opencode/src/cli/cmd/github.ts | 102 +++++++++++++---------- packages/opencode/src/lsp/server.ts | 104 +++++++++++++----------- 2 files changed, 117 insertions(+), 89 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 2491abc567d..92875e9c934 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" -import { $ } from "bun" import { setTimeout as sleep } from "node:timers/promises" +import { Process } from "@/util/process" +import { git } from "@/util/git" type GitHubAuthor = { login: string @@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({ } // Get repo info - const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim() + const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim() const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -493,6 +494,30 @@ export const GithubRunCommand = cmd({ ? "pr_review" : "issue" : undefined + const gitText = async (args: string[]) => { + const result = await git(args, { cwd: Instance.worktree }) + if (result.exitCode !== 0) { + throw new Error( + result.stderr.toString().trim() || result.stdout.toString().trim() || `git ${args.join(" ")} failed`, + ) + } + return result.text().trim() + } + const gitRun = async (args: string[]) => { + const result = await git(args, { cwd: Instance.worktree }) + if (result.exitCode !== 0) { + throw new Error( + result.stderr.toString().trim() || result.stdout.toString().trim() || `git ${args.join(" ")} failed`, + ) + } + return result + } + const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree }) + const commitChanges = async (summary: string, actor?: string) => { + const args = ["commit", "-m", summary] + if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) + await gitRun(args) + } try { if (useGithubToken) { @@ -553,7 +578,7 @@ export const GithubRunCommand = cmd({ } const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule" const branch = await checkoutNewBranch(branchPrefix) - const head = (await $`git rev-parse HEAD`).stdout.toString().trim() + const head = await gitText(["rev-parse", "HEAD"]) const response = await chat(userPrompt, promptFiles) const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch) if (switched) { @@ -587,7 +612,7 @@ export const GithubRunCommand = cmd({ // Local PR if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) { await checkoutLocalBranch(prData) - const head = (await $`git rev-parse HEAD`).stdout.toString().trim() + const head = await gitText(["rev-parse", "HEAD"]) const dataPrompt = buildPromptDataForPR(prData) const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles) const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName) @@ -605,7 +630,7 @@ export const GithubRunCommand = cmd({ // Fork PR else { const forkBranch = await checkoutForkBranch(prData) - const head = (await $`git rev-parse HEAD`).stdout.toString().trim() + const head = await gitText(["rev-parse", "HEAD"]) const dataPrompt = buildPromptDataForPR(prData) const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles) const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch) @@ -624,7 +649,7 @@ export const GithubRunCommand = cmd({ // Issue else { const branch = await checkoutNewBranch("issue") - const head = (await $`git rev-parse HEAD`).stdout.toString().trim() + const head = await gitText(["rev-parse", "HEAD"]) const issueData = await fetchIssue() const dataPrompt = buildPromptDataForIssue(issueData) const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles) @@ -658,7 +683,7 @@ export const GithubRunCommand = cmd({ exitCode = 1 console.error(e instanceof Error ? e.message : String(e)) let msg = e - if (e instanceof $.ShellError) { + if (e instanceof Process.RunFailedError) { msg = e.stderr.toString() } else if (e instanceof Error) { msg = e.message @@ -1049,29 +1074,29 @@ export const GithubRunCommand = cmd({ const config = "http.https://github.com/.extraheader" // actions/checkout@v6 no longer stores credentials in .git/config, // so this may not exist - use nothrow() to handle gracefully - const ret = await $`git config --local --get ${config}`.nothrow() + const ret = await gitStatus(["config", "--local", "--get", config]) if (ret.exitCode === 0) { gitConfig = ret.stdout.toString().trim() - await $`git config --local --unset-all ${config}` + await gitRun(["config", "--local", "--unset-all", config]) } const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64") - await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` - await $`git config --global user.name "${AGENT_USERNAME}"` - await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"` + await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`]) + await gitRun(["config", "--global", "user.name", AGENT_USERNAME]) + await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`]) } async function restoreGitConfig() { if (gitConfig === undefined) return const config = "http.https://github.com/.extraheader" - await $`git config --local ${config} "${gitConfig}"` + await gitRun(["config", "--local", config, gitConfig]) } async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") { console.log("Checking out new branch...") const branch = generateBranchName(type) - await $`git checkout -b ${branch}` + await gitRun(["checkout", "-b", branch]) return branch } @@ -1081,8 +1106,8 @@ export const GithubRunCommand = cmd({ const branch = pr.headRefName const depth = Math.max(pr.commits.totalCount, 20) - await $`git fetch origin --depth=${depth} ${branch}` - await $`git checkout ${branch}` + await gitRun(["fetch", "origin", `--depth=${depth}`, branch]) + await gitRun(["checkout", branch]) } async function checkoutForkBranch(pr: GitHubPullRequest) { @@ -1092,9 +1117,9 @@ export const GithubRunCommand = cmd({ const localBranch = generateBranchName("pr") const depth = Math.max(pr.commits.totalCount, 20) - await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git` - await $`git fetch fork --depth=${depth} ${remoteBranch}` - await $`git checkout -b ${localBranch} fork/${remoteBranch}` + await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`]) + await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch]) + await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`]) return localBranch } @@ -1115,28 +1140,23 @@ export const GithubRunCommand = cmd({ async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) { console.log("Pushing to new branch...") if (commit) { - await $`git add .` + await gitRun(["add", "."]) if (isSchedule) { - // No co-author for scheduled events - the schedule is operating as the repo - await $`git commit -m "${summary}"` + await commitChanges(summary) } else { - await $`git commit -m "${summary} - -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` + await commitChanges(summary, actor) } } - await $`git push -u origin ${branch}` + await gitRun(["push", "-u", "origin", branch]) } async function pushToLocalBranch(summary: string, commit: boolean) { console.log("Pushing to local branch...") if (commit) { - await $`git add .` - await $`git commit -m "${summary} - -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` + await gitRun(["add", "."]) + await commitChanges(summary, actor) } - await $`git push` + await gitRun(["push"]) } async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) { @@ -1145,30 +1165,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` const remoteBranch = pr.headRefName if (commit) { - await $`git add .` - await $`git commit -m "${summary} - -Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` + await gitRun(["add", "."]) + await commitChanges(summary, actor) } - await $`git push fork HEAD:${remoteBranch}` + await gitRun(["push", "fork", `HEAD:${remoteBranch}`]) } async function branchIsDirty(originalHead: string, expectedBranch: string) { console.log("Checking if branch is dirty...") // Detect if the agent switched branches during chat (e.g. created // its own branch, committed, and possibly pushed/created a PR). - const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim() + const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"]) if (current !== expectedBranch) { console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`) return { dirty: true, uncommittedChanges: false, switched: true } } - const ret = await $`git status --porcelain` + const ret = await gitStatus(["status", "--porcelain"]) const status = ret.stdout.toString().trim() if (status.length > 0) { return { dirty: true, uncommittedChanges: true, switched: false } } - const head = (await $`git rev-parse HEAD`).stdout.toString().trim() + const head = await gitText(["rev-parse", "HEAD"]) return { dirty: head !== originalHead, uncommittedChanges: false, @@ -1180,11 +1198,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` // Falls back to fetching from origin when local refs are missing // (common in shallow clones from actions/checkout). async function hasNewCommits(base: string, head: string) { - const result = await $`git rev-list --count ${base}..${head}`.nothrow() + const result = await gitStatus(["rev-list", "--count", `${base}..${head}`]) if (result.exitCode !== 0) { console.log(`rev-list failed, fetching origin/${base}...`) - await $`git fetch origin ${base} --depth=1`.nothrow() - const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow() + await gitStatus(["fetch", "origin", base, "--depth=1"]) + const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`]) if (retry.exitCode !== 0) return true // assume dirty if we can't tell return parseInt(retry.stdout.toString().trim()) > 0 } diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e09fbc97fe3..64348290745 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -4,7 +4,6 @@ import os from "os" import { Global } from "../global" import { Log } from "../util/log" import { BunProc } from "../bun" -import { $ } from "bun" import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" @@ -21,6 +20,8 @@ export namespace LSPServer { .stat(p) .then(() => true) .catch(() => false) + const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) + const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) export interface Handle { process: ChildProcessWithoutNullStreams @@ -205,8 +206,8 @@ export namespace LSPServer { await fs.rename(extractedPath, finalPath) const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" - await $`${npmCmd} install`.cwd(finalPath).quiet() - await $`${npmCmd} run compile`.cwd(finalPath).quiet() + await Process.run([npmCmd, "install"], { cwd: finalPath }) + await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) log.info("installed VS Code ESLint server", { serverPath }) } @@ -602,10 +603,11 @@ export namespace LSPServer { recursive: true, }) - await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release` - .quiet() - .cwd(path.join(Global.Path.bin, "elixir-ls-master")) - .env({ MIX_ENV: "prod", ...process.env }) + const cwd = path.join(Global.Path.bin, "elixir-ls-master") + const env = { MIX_ENV: "prod", ...process.env } + await Process.run(["mix", "deps.get"], { cwd, env }) + await Process.run(["mix", "compile"], { cwd, env }) + await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) log.info(`installed elixir-ls`, { path: elixirLsPath, @@ -706,7 +708,7 @@ export namespace LSPServer { }) if (!ok) return } else { - await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow() + await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin }) } await fs.rm(tempPath, { force: true }) @@ -719,7 +721,7 @@ export namespace LSPServer { } if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() + await fs.chmod(bin, 0o755).catch(() => {}) } log.info(`installed zls`, { bin }) @@ -831,11 +833,11 @@ export namespace LSPServer { // This is specific to macOS where sourcekit-lsp is typically installed with Xcode if (!which("xcrun")) return - const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow() + const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"]) - if (lspLoc.exitCode !== 0) return + if (lspLoc.code !== 0) return - const bin = lspLoc.text().trim() + const bin = lspLoc.text.trim() return { process: spawn(bin, { @@ -1010,7 +1012,7 @@ export namespace LSPServer { if (!ok) return } if (tar) { - await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow() + await run(["tar", "-xf", archive], { cwd: Global.Path.bin }) } await fs.rm(archive, { force: true }) @@ -1021,7 +1023,7 @@ export namespace LSPServer { } if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() + await fs.chmod(bin, 0o755).catch(() => {}) } await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) @@ -1138,13 +1140,10 @@ export namespace LSPServer { log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") return } - const javaMajorVersion = await $`java -version` - .quiet() - .nothrow() - .then(({ stderr }) => { - const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString()) - return !m ? undefined : parseInt(m[1]) - }) + const javaMajorVersion = await run(["java", "-version"]).then((result) => { + const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString()) + return !m ? undefined : parseInt(m[1]) + }) if (javaMajorVersion == null || javaMajorVersion < 21) { log.error("JDTLS requires at least Java 21.") return @@ -1161,27 +1160,27 @@ export namespace LSPServer { const archiveName = "release.tar.gz" log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) - const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow() - if (curlResult.exitCode !== 0) { - log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() }) + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText }) return } + await Filesystem.writeStream(path.join(distPath, archiveName), download.body) log.info("Extracting JDTLS archive") - const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow() - if (tarResult.exitCode !== 0) { - log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() }) + const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) + if (tarResult.code !== 0) { + log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() }) return } await fs.rm(path.join(distPath, archiveName), { force: true }) log.info("JDTLS download and extraction completed") } - const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar` - .cwd(launcherDir) - .quiet() - .nothrow() - .then(({ stdout }) => stdout.toString().trim()) + const jarFileName = + (await fs.readdir(launcherDir).catch(() => [])) + .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) + ?.trim() ?? "" const launcherJar = path.join(launcherDir, jarFileName) if (!(await pathExists(launcherJar))) { log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) @@ -1294,7 +1293,15 @@ export namespace LSPServer { await fs.mkdir(distPath, { recursive: true }) const archivePath = path.join(distPath, "kotlin-ls.zip") - await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow() + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + log.error("Failed to download Kotlin Language Server", { + status: download.status, + statusText: download.statusText, + }) + return + } + await Filesystem.writeStream(archivePath, download.body) const ok = await Archive.extractZip(archivePath, distPath) .then(() => true) .catch((error) => { @@ -1304,7 +1311,7 @@ export namespace LSPServer { if (!ok) return await fs.rm(archivePath, { force: true }) if (process.platform !== "win32") { - await $`chmod +x ${launcherScript}`.quiet().nothrow() + await fs.chmod(launcherScript, 0o755).catch(() => {}) } log.info("Installed Kotlin Language Server", { path: launcherScript }) } @@ -1468,10 +1475,9 @@ export namespace LSPServer { }) if (!ok) return } else { - const ok = await $`tar -xzf ${tempPath} -C ${installDir}` - .quiet() - .then(() => true) - .catch((error) => { + const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) + .then((result) => result.code === 0) + .catch((error: unknown) => { log.error("Failed to extract lua-language-server archive", { error }) return false }) @@ -1489,11 +1495,15 @@ export namespace LSPServer { } if (platform !== "win32") { - const ok = await $`chmod +x ${bin}`.quiet().catch((error) => { - log.error("Failed to set executable permission for lua-language-server binary", { - error, + const ok = await fs + .chmod(bin, 0o755) + .then(() => true) + .catch((error: unknown) => { + log.error("Failed to set executable permission for lua-language-server binary", { + error, + }) + return false }) - }) if (!ok) return } @@ -1707,7 +1717,7 @@ export namespace LSPServer { } if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() + await fs.chmod(bin, 0o755).catch(() => {}) } log.info(`installed terraform-ls`, { bin }) @@ -1790,7 +1800,7 @@ export namespace LSPServer { if (!ok) return } if (ext === "tar.gz") { - await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow() + await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin }) } await fs.rm(tempPath, { force: true }) @@ -1803,7 +1813,7 @@ export namespace LSPServer { } if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() + await fs.chmod(bin, 0o755).catch(() => {}) } log.info("installed texlab", { bin }) @@ -1995,7 +2005,7 @@ export namespace LSPServer { }) if (!ok) return } else { - await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow() + await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin }) } await fs.rm(tempPath, { force: true }) @@ -2008,7 +2018,7 @@ export namespace LSPServer { } if (platform !== "win32") { - await $`chmod +x ${bin}`.quiet().nothrow() + await fs.chmod(bin, 0o755).catch(() => {}) } log.info("installed tinymist", { bin }) From 3b2e3afebdbdbf6e3cd2f35a29bd72e69a93347e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Mar 2026 01:25:04 -0500 Subject: [PATCH 3/5] fix(opencode): address migration review feedback --- packages/opencode/src/cli/cmd/github.ts | 8 ++------ packages/opencode/src/installation/index.ts | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 92875e9c934..a58151308c2 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -497,18 +497,14 @@ export const GithubRunCommand = cmd({ const gitText = async (args: string[]) => { const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { - throw new Error( - result.stderr.toString().trim() || result.stdout.toString().trim() || `git ${args.join(" ")} failed`, - ) + throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { const result = await git(args, { cwd: Instance.worktree }) if (result.exitCode !== 0) { - throw new Error( - result.stderr.toString().trim() || result.stdout.toString().trim() || `git ${args.join(" ")} failed`, - ) + throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 90c64226dbb..8d21ac78207 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -162,7 +162,7 @@ export namespace Installation { } export async function upgrade(method: Method, target: string) { - let result + let result: Awaited> | undefined switch (method) { case "curl": result = await upgradeCurl(target) @@ -183,15 +183,24 @@ export namespace Installation { ...process.env, } if (formula.includes("/")) { - await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) + const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) + if (tap.code !== 0) { + result = tap + break + } const repo = (await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })).text.trim() - if (repo) await Process.run(["git", "pull", "--ff-only"], { cwd: repo, env, nothrow: true }) - result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true }) - break + if (repo) { + const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: repo, env, nothrow: true }) + if (pull.code !== 0) { + result = pull + break + } + } } result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true }) break } + case "choco": result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) break From 054f8483075266fe49763d662a05c6e73f1eeb5d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Mar 2026 01:30:12 -0500 Subject: [PATCH 4/5] fix(opencode): honor nothrow on spawn errors --- packages/opencode/src/util/process.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts index 1549be0b6cd..473ee27dc97 100644 --- a/packages/opencode/src/util/process.ts +++ b/packages/opencode/src/util/process.ts @@ -118,12 +118,20 @@ export namespace Process { if (!proc.stdout || !proc.stderr) throw new Error("Process output not available") - const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) - const out = { - code, - stdout, - stderr, - } + const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) + .then(([code, stdout, stderr]) => ({ + code, + stdout, + stderr, + })) + .catch((err: unknown) => { + if (!opts.nothrow) throw err + return { + code: 1, + stdout: Buffer.alloc(0), + stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + } + }) if (out.code === 0 || opts.nothrow) return out throw new RunFailedError(cmd, out.code, out.stdout, out.stderr) } From add16af117aae69547346c4f57206f111594276a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Mar 2026 01:33:59 -0500 Subject: [PATCH 5/5] fix(opencode): fail on brew repo lookup errors --- packages/opencode/src/installation/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 8d21ac78207..92a3bfc7961 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -188,9 +188,14 @@ export namespace Installation { result = tap break } - const repo = (await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })).text.trim() - if (repo) { - const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: repo, env, nothrow: true }) + const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true }) + if (repo.code !== 0) { + result = repo + break + } + const dir = repo.text.trim() + if (dir) { + const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true }) if (pull.code !== 0) { result = pull break