diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 79aaae2bcc4..d4e42938053 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -4,20 +4,21 @@ import { Log } from "../util/log" import path from "path" import { Filesystem } from "../util/filesystem" import { NamedError } from "@opencode-ai/util/error" -import { readableStreamToText } from "bun" +import { text } from "node:stream/consumers" import { Lock } from "../util/lock" import { PackageRegistry } from "./registry" import { proxied } from "@/util/proxied" +import { Process } from "../util/process" export namespace BunProc { const log = Log.create({ service: "bun" }) - export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject) { + export async function run(cmd: string[], options?: Process.Options) { log.info("running", { cmd: [which(), ...cmd], ...options, }) - const result = Bun.spawn([which(), ...cmd], { + const result = Process.spawn([which(), ...cmd], { ...options, stdout: "pipe", stderr: "pipe", @@ -28,23 +29,15 @@ export namespace BunProc { }, }) const code = await result.exited - const stdout = result.stdout - ? typeof result.stdout === "number" - ? result.stdout - : await readableStreamToText(result.stdout) - : undefined - const stderr = result.stderr - ? typeof result.stderr === "number" - ? result.stderr - : await readableStreamToText(result.stderr) - : undefined + const stdout = result.stdout ? await text(result.stdout) : undefined + const stderr = result.stderr ? await text(result.stderr) : undefined log.info("done", { code, stdout, stderr, }) if (code !== 0) { - throw new Error(`Command failed with exit code ${result.exitCode}`) + throw new Error(`Command failed with exit code ${code}`) } return result } diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts index c567668acd7..a85a6c989c8 100644 --- a/packages/opencode/src/bun/registry.ts +++ b/packages/opencode/src/bun/registry.ts @@ -1,5 +1,7 @@ -import { readableStreamToText, semver } from "bun" +import { semver } from "bun" +import { text } from "node:stream/consumers" import { Log } from "../util/log" +import { Process } from "../util/process" export namespace PackageRegistry { const log = Log.create({ service: "bun" }) @@ -9,7 +11,7 @@ export namespace PackageRegistry { } export async function info(pkg: string, field: string, cwd?: string): Promise { - const result = Bun.spawn([which(), "info", pkg, field], { + const result = Process.spawn([which(), "info", pkg, field], { cwd, stdout: "pipe", stderr: "pipe", @@ -20,8 +22,8 @@ export namespace PackageRegistry { }) const code = await result.exited - const stdout = result.stdout ? await readableStreamToText(result.stdout) : "" - const stderr = result.stderr ? await readableStreamToText(result.stderr) : "" + const stdout = result.stdout ? await text(result.stdout) : "" + const stderr = result.stderr ? await text(result.stderr) : "" if (code !== 0) { log.warn("bun info failed", { pkg, field, code, stderr }) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index e050a0abf80..4a97a5e0b83 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -11,6 +11,8 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" +import { Process } from "../../util/process" +import { text } from "node:stream/consumers" type PluginAuth = NonNullable @@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({ if (args.url) { const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Bun.spawn({ - cmd: wellknown.auth.command, + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", }) const exit = await proc.exited @@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({ prompts.outro("Done") return } - const token = await new Response(proc.stdout).text() + if (!proc.stdout) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + const token = await text(proc.stdout) await Auth.set(args.url, { type: "wellknown", key: wellknown.auth.env, diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 4aa702359d1..7fb5fda97b9 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -6,6 +6,7 @@ import { UI } from "../ui" import { Locale } from "../../util/locale" import { Flag } from "../../flag/flag" import { Filesystem } from "../../util/filesystem" +import { Process } from "../../util/process" import { EOL } from "os" import path from "path" @@ -102,13 +103,17 @@ export const SessionListCommand = cmd({ const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" if (shouldPaginate) { - const proc = Bun.spawn({ - cmd: pagerCmd(), + const proc = Process.spawn(pagerCmd(), { stdin: "pipe", stdout: "inherit", stderr: "inherit", }) + if (!proc.stdin) { + console.log(output) + return + } + proc.stdin.write(output) proc.stdin.end() await proc.exited diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 7d1aad3a86e..1a8197bf4e8 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" import { Filesystem } from "../../../../util/filesystem" +import { Process } from "../../../../util/process" /** * Writes text to clipboard via OSC 52 escape sequence. @@ -87,7 +88,8 @@ export namespace Clipboard { if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) { console.log("clipboard: using wl-copy") return async (text: string) => { - const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) + const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) + if (!proc.stdin) return proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) @@ -96,11 +98,12 @@ export namespace Clipboard { if (Bun.which("xclip")) { console.log("clipboard: using xclip") return async (text: string) => { - const proc = Bun.spawn(["xclip", "-selection", "clipboard"], { + const proc = Process.spawn(["xclip", "-selection", "clipboard"], { stdin: "pipe", stdout: "ignore", stderr: "ignore", }) + if (!proc.stdin) return proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) @@ -109,11 +112,12 @@ export namespace Clipboard { if (Bun.which("xsel")) { console.log("clipboard: using xsel") return async (text: string) => { - const proc = Bun.spawn(["xsel", "--clipboard", "--input"], { + const proc = Process.spawn(["xsel", "--clipboard", "--input"], { stdin: "pipe", stdout: "ignore", stderr: "ignore", }) + if (!proc.stdin) return proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) @@ -125,7 +129,7 @@ export namespace Clipboard { console.log("clipboard: using powershell") return async (text: string) => { // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.) - const proc = Bun.spawn( + const proc = Process.spawn( [ "powershell.exe", "-NonInteractive", @@ -140,6 +144,7 @@ export namespace Clipboard { }, ) + if (!proc.stdin) return proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) diff --git a/packages/opencode/src/cli/cmd/tui/util/editor.ts b/packages/opencode/src/cli/cmd/tui/util/editor.ts index cb7c691bbde..6d32c63c001 100644 --- a/packages/opencode/src/cli/cmd/tui/util/editor.ts +++ b/packages/opencode/src/cli/cmd/tui/util/editor.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { CliRenderer } from "@opentui/core" import { Filesystem } from "@/util/filesystem" +import { Process } from "@/util/process" export namespace Editor { export async function open(opts: { value: string; renderer: CliRenderer }): Promise { @@ -17,8 +18,7 @@ export namespace Editor { opts.renderer.suspend() opts.renderer.currentRenderBuffer.clear() const parts = editor.split(" ") - const proc = Bun.spawn({ - cmd: [...parts, filepath], + const proc = Process.spawn([...parts, filepath], { stdin: "inherit", stdout: "inherit", stderr: "inherit", diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index ca1eadae8e0..9c4e9cf0284 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -7,6 +7,8 @@ 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 { text } from "node:stream/consumers" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { Log } from "@/util/log" @@ -153,17 +155,19 @@ export namespace Ripgrep { if (platformKey.endsWith("-darwin")) args.push("--include=*/rg") if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg") - const proc = Bun.spawn(args, { + const proc = Process.spawn(args, { cwd: Global.Path.bin, stderr: "pipe", stdout: "pipe", }) - await proc.exited - if (proc.exitCode !== 0) + const exit = await proc.exited + if (exit !== 0) { + const stderr = proc.stderr ? await text(proc.stderr) : "" throw new ExtractionFailedError({ filepath, - stderr: await Bun.readableStreamToText(proc.stderr), + stderr, }) + } } if (config.extension === "zip") { const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer]))) @@ -227,8 +231,7 @@ export namespace Ripgrep { } } - // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. - // See https://github.com/oven-sh/bun/issues/24012 + // Guard against invalid cwd to provide a consistent ENOENT error. if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) { throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { code: "ENOENT", @@ -237,41 +240,35 @@ export namespace Ripgrep { }) } - const proc = Bun.spawn(args, { + const proc = Process.spawn(args, { cwd: input.cwd, stdout: "pipe", stderr: "ignore", - maxBuffer: 1024 * 1024 * 20, - signal: input.signal, + abort: input.signal, }) - const reader = proc.stdout.getReader() - const decoder = new TextDecoder() - let buffer = "" - - try { - while (true) { - input.signal?.throwIfAborted() + if (!proc.stdout) { + throw new Error("Process output not available") + } - const { done, value } = await reader.read() - if (done) break + let buffer = "" + const stream = proc.stdout as AsyncIterable + for await (const chunk of stream) { + input.signal?.throwIfAborted() - buffer += decoder.decode(value, { stream: true }) - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() || "" + buffer += typeof chunk === "string" ? chunk : chunk.toString() + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || "" - for (const line of lines) { - if (line) yield line - } + for (const line of lines) { + if (line) yield line } - - if (buffer) yield buffer - } finally { - reader.releaseLock() - await proc.exited } + if (buffer) yield buffer + await proc.exited + input.signal?.throwIfAborted() } diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 47b2d6a12d2..19b9e2cbe97 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -1,7 +1,8 @@ -import { readableStreamToText } from "bun" +import { text } from "node:stream/consumers" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Filesystem } from "../util/filesystem" +import { Process } from "../util/process" import { Flag } from "@/flag/flag" export interface Info { @@ -213,12 +214,13 @@ export const rlang: Info = { if (airPath == null) return false try { - const proc = Bun.spawn(["air", "--help"], { + const proc = Process.spawn(["air", "--help"], { stdout: "pipe", stderr: "pipe", }) await proc.exited - const output = await readableStreamToText(proc.stdout) + if (!proc.stdout) return false + const output = await text(proc.stdout) // Check for "Air: An R language server and formatter" const firstLine = output.split("\n")[0] @@ -238,7 +240,7 @@ export const uvformat: Info = { async enabled() { if (await ruff.enabled()) return false if (Bun.which("uv") !== null) { - const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) + const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" }) const code = await proc.exited return code === 0 } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index bab758030b9..b849f778ece 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -8,6 +8,7 @@ import * as Formatter from "./formatter" import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" +import { Process } from "../util/process" export namespace Format { const log = Log.create({ service: "format" }) @@ -110,13 +111,15 @@ export namespace Format { for (const item of await getFormatter(ext)) { log.info("running", { command: item.command }) try { - const proc = Bun.spawn({ - cmd: item.command.map((x) => x.replace("$FILE", file)), - cwd: Instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }) + const proc = Process.spawn( + item.command.map((x) => x.replace("$FILE", file)), + { + cwd: Instance.directory, + env: { ...process.env, ...item.environment }, + stdout: "ignore", + stderr: "ignore", + }, + ) const exit = await proc.exited if (exit !== 0) log.error("failed", { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index a4ebeb5a256..afd297a5ed6 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -4,12 +4,14 @@ import os from "os" import { Global } from "../global" import { Log } from "../util/log" import { BunProc } from "../bun" -import { $, readableStreamToText } from "bun" +import { $ } from "bun" +import { text } from "node:stream/consumers" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { Archive } from "../util/archive" +import { Process } from "../util/process" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) @@ -133,7 +135,7 @@ export namespace LSPServer { ) if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], { + await Process.spawn([BunProc.which(), "install", "@vue/language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -263,14 +265,16 @@ export namespace LSPServer { } if (lintBin) { - const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" }) + const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" }) await proc.exited - const help = await readableStreamToText(proc.stdout) - if (help.includes("--lsp")) { - return { - process: spawn(lintBin, ["--lsp"], { - cwd: root, - }), + if (proc.stdout) { + const help = await text(proc.stdout) + if (help.includes("--lsp")) { + return { + process: spawn(lintBin, ["--lsp"], { + cwd: root, + }), + } } } } @@ -372,8 +376,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing gopls") - const proc = Bun.spawn({ - cmd: ["go", "install", "golang.org/x/tools/gopls@latest"], + const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { env: { ...process.env, GOBIN: Global.Path.bin }, stdout: "pipe", stderr: "pipe", @@ -414,8 +417,7 @@ export namespace LSPServer { } if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing rubocop") - const proc = Bun.spawn({ - cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin], + const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -513,7 +515,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "pyright"], { + await Process.spawn([BunProc.which(), "install", "pyright"], { cwd: Global.Path.bin, env: { ...process.env, @@ -746,8 +748,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing csharp-ls via dotnet tool") - const proc = Bun.spawn({ - cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], + const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -786,8 +787,7 @@ export namespace LSPServer { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("installing fsautocomplete via dotnet tool") - const proc = Bun.spawn({ - cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], + const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { stdout: "pipe", stderr: "pipe", stdin: "pipe", @@ -1047,7 +1047,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], { + await Process.spawn([BunProc.which(), "install", "svelte-language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1094,7 +1094,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], { + await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1339,7 +1339,7 @@ export namespace LSPServer { const exists = await Filesystem.exists(js) if (!exists) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], { + await Process.spawn([BunProc.which(), "install", "yaml-language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1518,7 +1518,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "intelephense"], { + await Process.spawn([BunProc.which(), "install", "intelephense"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1615,7 +1615,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "bash-language-server"], { + await Process.spawn([BunProc.which(), "install", "bash-language-server"], { cwd: Global.Path.bin, env: { ...process.env, @@ -1827,7 +1827,7 @@ export namespace LSPServer { const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") if (!(await Filesystem.exists(js))) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { + await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { cwd: Global.Path.bin, env: { ...process.env, diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 00497d4e3fd..82e7ac1667e 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -1,7 +1,9 @@ import z from "zod" +import { text } from "node:stream/consumers" import { Tool } from "./tool" import { Filesystem } from "../util/filesystem" import { Ripgrep } from "../file/ripgrep" +import { Process } from "../util/process" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" @@ -44,14 +46,18 @@ export const GrepTool = Tool.define("grep", { } args.push(searchPath) - const proc = Bun.spawn([rgPath, ...args], { + const proc = Process.spawn([rgPath, ...args], { stdout: "pipe", stderr: "pipe", - signal: ctx.abort, + abort: ctx.abort, }) - const output = await new Response(proc.stdout).text() - const errorOutput = await new Response(proc.stderr).text() + if (!proc.stdout || !proc.stderr) { + throw new Error("Process output not available") + } + + const output = await text(proc.stdout) + const errorOutput = await text(proc.stderr) const exitCode = await proc.exited // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts index 201def36a8c..8e1427c99d5 100644 --- a/packages/opencode/src/util/git.ts +++ b/packages/opencode/src/util/git.ts @@ -1,5 +1,7 @@ import { $ } from "bun" +import { buffer } from "node:stream/consumers" import { Flag } from "../flag/flag" +import { Process } from "./process" export interface GitResult { exitCode: number @@ -14,12 +16,12 @@ export interface GitResult { * Uses Bun's lightweight `$` shell by default. When the process is running * as an ACP client, child processes inherit the parent's stdin pipe which * carries protocol data – on Windows this causes git to deadlock. In that - * case we fall back to `Bun.spawn` with `stdin: "ignore"`. + * case we fall back to `Process.spawn` with `stdin: "ignore"`. */ export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise { if (Flag.OPENCODE_CLIENT === "acp") { try { - const proc = Bun.spawn(["git", ...args], { + const proc = Process.spawn(["git", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -27,18 +29,15 @@ export async function git(args: string[], opts: { cwd: string; env?: Record stdoutBuf.toString(), - stdout: stdoutBuf, - stderr: stderrBuf, + text: () => out.toString(), + stdout: out, + stderr: err, } } catch (error) { const stderr = Buffer.from(error instanceof Error ? error.message : String(error)) diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts new file mode 100644 index 00000000000..09c55661fdd --- /dev/null +++ b/packages/opencode/src/util/process.ts @@ -0,0 +1,71 @@ +import { spawn as launch, type ChildProcess } from "child_process" + +export namespace Process { + export type Stdio = "inherit" | "pipe" | "ignore" + + export interface Options { + cwd?: string + env?: NodeJS.ProcessEnv | null + stdin?: Stdio + stdout?: Stdio + stderr?: Stdio + abort?: AbortSignal + kill?: NodeJS.Signals | number + timeout?: number + } + + export type Child = ChildProcess & { exited: Promise } + + export function spawn(cmd: string[], options: Options = {}): Child { + if (cmd.length === 0) throw new Error("Command is required") + options.abort?.throwIfAborted() + + const proc = launch(cmd[0], cmd.slice(1), { + cwd: options.cwd, + env: options.env === null ? {} : options.env ? { ...process.env, ...options.env } : undefined, + stdio: [options.stdin ?? "ignore", options.stdout ?? "ignore", options.stderr ?? "ignore"], + }) + + let aborted = false + let timer: ReturnType | undefined + + const abort = () => { + if (aborted) return + if (proc.exitCode !== null || proc.signalCode !== null) return + aborted = true + + proc.kill(options.kill ?? "SIGTERM") + + const timeout = options.timeout ?? 5_000 + if (timeout <= 0) return + + timer = setTimeout(() => { + proc.kill("SIGKILL") + }, timeout) + } + + const exited = new Promise((resolve, reject) => { + const done = () => { + options.abort?.removeEventListener("abort", abort) + if (timer) clearTimeout(timer) + } + proc.once("exit", (exitCode, signal) => { + done() + resolve(exitCode ?? (signal ? 1 : 0)) + }) + proc.once("error", (error) => { + done() + reject(error) + }) + }) + + if (options.abort) { + options.abort.addEventListener("abort", abort, { once: true }) + if (options.abort.aborted) abort() + } + + const child = proc as Child + child.exited = exited + return child + } +}