From 1454dd1dc1867af9177b41a2218c651afb5eb213 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Mar 2026 01:09:00 -0500 Subject: [PATCH] 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 2491abc567..92875e9c93 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 e09fbc97fe..6434829074 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 })