refactor: migrate Bun.spawn to Process utility with timeout and cleanup (#14448)
This commit is contained in:
@@ -4,20 +4,21 @@ import { Log } from "../util/log"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { readableStreamToText } from "bun"
|
import { text } from "node:stream/consumers"
|
||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
import { PackageRegistry } from "./registry"
|
import { PackageRegistry } from "./registry"
|
||||||
import { proxied } from "@/util/proxied"
|
import { proxied } from "@/util/proxied"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
|
|
||||||
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
|
export async function run(cmd: string[], options?: Process.Options) {
|
||||||
log.info("running", {
|
log.info("running", {
|
||||||
cmd: [which(), ...cmd],
|
cmd: [which(), ...cmd],
|
||||||
...options,
|
...options,
|
||||||
})
|
})
|
||||||
const result = Bun.spawn([which(), ...cmd], {
|
const result = Process.spawn([which(), ...cmd], {
|
||||||
...options,
|
...options,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
@@ -28,23 +29,15 @@ export namespace BunProc {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const code = await result.exited
|
const code = await result.exited
|
||||||
const stdout = result.stdout
|
const stdout = result.stdout ? await text(result.stdout) : undefined
|
||||||
? typeof result.stdout === "number"
|
const stderr = result.stderr ? await text(result.stderr) : undefined
|
||||||
? result.stdout
|
|
||||||
: await readableStreamToText(result.stdout)
|
|
||||||
: undefined
|
|
||||||
const stderr = result.stderr
|
|
||||||
? typeof result.stderr === "number"
|
|
||||||
? result.stderr
|
|
||||||
: await readableStreamToText(result.stderr)
|
|
||||||
: undefined
|
|
||||||
log.info("done", {
|
log.info("done", {
|
||||||
code,
|
code,
|
||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
})
|
})
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
throw new Error(`Command failed with exit code ${result.exitCode}`)
|
throw new Error(`Command failed with exit code ${code}`)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Log } from "../util/log"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
|
||||||
export namespace PackageRegistry {
|
export namespace PackageRegistry {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
|
||||||
const result = Bun.spawn([which(), "info", pkg, field], {
|
const result = Process.spawn([which(), "info", pkg, field], {
|
||||||
cwd,
|
cwd,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const code = await result.exited
|
const code = await result.exited
|
||||||
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
|
const stdout = result.stdout ? await text(result.stdout) : ""
|
||||||
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
|
const stderr = result.stderr ? await text(result.stderr) : ""
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
log.warn("bun info failed", { pkg, field, code, stderr })
|
log.warn("bun info failed", { pkg, field, code, stderr })
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Global } from "../../global"
|
|||||||
import { Plugin } from "../../plugin"
|
import { Plugin } from "../../plugin"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
import type { Hooks } from "@opencode-ai/plugin"
|
import type { Hooks } from "@opencode-ai/plugin"
|
||||||
|
import { Process } from "../../util/process"
|
||||||
|
import { text } from "node:stream/consumers"
|
||||||
|
|
||||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||||
|
|
||||||
@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
|
|||||||
if (args.url) {
|
if (args.url) {
|
||||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(wellknown.auth.command, {
|
||||||
cmd: wellknown.auth.command,
|
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
})
|
})
|
||||||
const exit = await proc.exited
|
const exit = await proc.exited
|
||||||
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
|
|||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
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, {
|
await Auth.set(args.url, {
|
||||||
type: "wellknown",
|
type: "wellknown",
|
||||||
key: wellknown.auth.env,
|
key: wellknown.auth.env,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { UI } from "../ui"
|
|||||||
import { Locale } from "../../util/locale"
|
import { Locale } from "../../util/locale"
|
||||||
import { Flag } from "../../flag/flag"
|
import { Flag } from "../../flag/flag"
|
||||||
import { Filesystem } from "../../util/filesystem"
|
import { Filesystem } from "../../util/filesystem"
|
||||||
|
import { Process } from "../../util/process"
|
||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
|
|||||||
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
|
||||||
|
|
||||||
if (shouldPaginate) {
|
if (shouldPaginate) {
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(pagerCmd(), {
|
||||||
cmd: pagerCmd(),
|
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!proc.stdin) {
|
||||||
|
console.log(output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
proc.stdin.write(output)
|
proc.stdin.write(output)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited
|
await proc.exited
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
|
|||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Filesystem } from "../../../../util/filesystem"
|
import { Filesystem } from "../../../../util/filesystem"
|
||||||
|
import { Process } from "../../../../util/process"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes text to clipboard via OSC 52 escape sequence.
|
* 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")) {
|
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||||
console.log("clipboard: using wl-copy")
|
console.log("clipboard: using wl-copy")
|
||||||
return async (text: string) => {
|
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.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
@@ -96,11 +98,12 @@ export namespace Clipboard {
|
|||||||
if (Bun.which("xclip")) {
|
if (Bun.which("xclip")) {
|
||||||
console.log("clipboard: using xclip")
|
console.log("clipboard: using xclip")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
})
|
})
|
||||||
|
if (!proc.stdin) return
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
@@ -109,11 +112,12 @@ export namespace Clipboard {
|
|||||||
if (Bun.which("xsel")) {
|
if (Bun.which("xsel")) {
|
||||||
console.log("clipboard: using xsel")
|
console.log("clipboard: using xsel")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
|
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
stdout: "ignore",
|
stdout: "ignore",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
})
|
})
|
||||||
|
if (!proc.stdin) return
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
@@ -125,7 +129,7 @@ export namespace Clipboard {
|
|||||||
console.log("clipboard: using powershell")
|
console.log("clipboard: using powershell")
|
||||||
return async (text: string) => {
|
return async (text: string) => {
|
||||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||||
const proc = Bun.spawn(
|
const proc = Process.spawn(
|
||||||
[
|
[
|
||||||
"powershell.exe",
|
"powershell.exe",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
@@ -140,6 +144,7 @@ export namespace Clipboard {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!proc.stdin) return
|
||||||
proc.stdin.write(text)
|
proc.stdin.write(text)
|
||||||
proc.stdin.end()
|
proc.stdin.end()
|
||||||
await proc.exited.catch(() => {})
|
await proc.exited.catch(() => {})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { CliRenderer } from "@opentui/core"
|
import { CliRenderer } from "@opentui/core"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { Process } from "@/util/process"
|
||||||
|
|
||||||
export namespace Editor {
|
export namespace Editor {
|
||||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||||
@@ -17,8 +18,7 @@ export namespace Editor {
|
|||||||
opts.renderer.suspend()
|
opts.renderer.suspend()
|
||||||
opts.renderer.currentRenderBuffer.clear()
|
opts.renderer.currentRenderBuffer.clear()
|
||||||
const parts = editor.split(" ")
|
const parts = editor.split(" ")
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn([...parts, filepath], {
|
||||||
cmd: [...parts, filepath],
|
|
||||||
stdin: "inherit",
|
stdin: "inherit",
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Filesystem } from "../util/filesystem"
|
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 { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
@@ -153,17 +155,19 @@ export namespace Ripgrep {
|
|||||||
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||||
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||||
|
|
||||||
const proc = Bun.spawn(args, {
|
const proc = Process.spawn(args, {
|
||||||
cwd: Global.Path.bin,
|
cwd: Global.Path.bin,
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
})
|
})
|
||||||
await proc.exited
|
const exit = await proc.exited
|
||||||
if (proc.exitCode !== 0)
|
if (exit !== 0) {
|
||||||
|
const stderr = proc.stderr ? await text(proc.stderr) : ""
|
||||||
throw new ExtractionFailedError({
|
throw new ExtractionFailedError({
|
||||||
filepath,
|
filepath,
|
||||||
stderr: await Bun.readableStreamToText(proc.stderr),
|
stderr,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.extension === "zip") {
|
if (config.extension === "zip") {
|
||||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
|
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.
|
// Guard against invalid cwd to provide a consistent ENOENT error.
|
||||||
// See https://github.com/oven-sh/bun/issues/24012
|
|
||||||
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
|
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
|
||||||
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||||
code: "ENOENT",
|
code: "ENOENT",
|
||||||
@@ -237,41 +240,35 @@ export namespace Ripgrep {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = Bun.spawn(args, {
|
const proc = Process.spawn(args, {
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "ignore",
|
stderr: "ignore",
|
||||||
maxBuffer: 1024 * 1024 * 20,
|
abort: input.signal,
|
||||||
signal: input.signal,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const reader = proc.stdout.getReader()
|
if (!proc.stdout) {
|
||||||
const decoder = new TextDecoder()
|
throw new Error("Process output not available")
|
||||||
let buffer = ""
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
input.signal?.throwIfAborted()
|
|
||||||
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
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() || ""
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line) yield line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer) yield buffer
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock()
|
|
||||||
await proc.exited
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let buffer = ""
|
||||||
|
const stream = proc.stdout as AsyncIterable<Buffer | string>
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
input.signal?.throwIfAborted()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer) yield buffer
|
||||||
|
await proc.exited
|
||||||
|
|
||||||
input.signal?.throwIfAborted()
|
input.signal?.throwIfAborted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { readableStreamToText } from "bun"
|
import { text } from "node:stream/consumers"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Process } from "../util/process"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
export interface Info {
|
export interface Info {
|
||||||
@@ -213,12 +214,13 @@ export const rlang: Info = {
|
|||||||
if (airPath == null) return false
|
if (airPath == null) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["air", "--help"], {
|
const proc = Process.spawn(["air", "--help"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
})
|
})
|
||||||
await proc.exited
|
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"
|
// Check for "Air: An R language server and formatter"
|
||||||
const firstLine = output.split("\n")[0]
|
const firstLine = output.split("\n")[0]
|
||||||
@@ -238,7 +240,7 @@ export const uvformat: Info = {
|
|||||||
async enabled() {
|
async enabled() {
|
||||||
if (await ruff.enabled()) return false
|
if (await ruff.enabled()) return false
|
||||||
if (Bun.which("uv") !== null) {
|
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
|
const code = await proc.exited
|
||||||
return code === 0
|
return code === 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
|
|||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
const log = Log.create({ service: "format" })
|
const log = Log.create({ service: "format" })
|
||||||
@@ -110,13 +111,15 @@ export namespace Format {
|
|||||||
for (const item of await getFormatter(ext)) {
|
for (const item of await getFormatter(ext)) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(
|
||||||
cmd: item.command.map((x) => x.replace("$FILE", file)),
|
item.command.map((x) => x.replace("$FILE", file)),
|
||||||
cwd: Instance.directory,
|
{
|
||||||
env: { ...process.env, ...item.environment },
|
cwd: Instance.directory,
|
||||||
stdout: "ignore",
|
env: { ...process.env, ...item.environment },
|
||||||
stderr: "ignore",
|
stdout: "ignore",
|
||||||
})
|
stderr: "ignore",
|
||||||
|
},
|
||||||
|
)
|
||||||
const exit = await proc.exited
|
const exit = await proc.exited
|
||||||
if (exit !== 0)
|
if (exit !== 0)
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import os from "os"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { $, readableStreamToText } from "bun"
|
import { $ } from "bun"
|
||||||
|
import { text } from "node:stream/consumers"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Archive } from "../util/archive"
|
import { Archive } from "../util/archive"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
|
||||||
export namespace LSPServer {
|
export namespace LSPServer {
|
||||||
const log = Log.create({ service: "lsp.server" })
|
const log = Log.create({ service: "lsp.server" })
|
||||||
@@ -133,7 +135,7 @@ export namespace LSPServer {
|
|||||||
)
|
)
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -263,14 +265,16 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lintBin) {
|
if (lintBin) {
|
||||||
const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" })
|
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
|
||||||
await proc.exited
|
await proc.exited
|
||||||
const help = await readableStreamToText(proc.stdout)
|
if (proc.stdout) {
|
||||||
if (help.includes("--lsp")) {
|
const help = await text(proc.stdout)
|
||||||
return {
|
if (help.includes("--lsp")) {
|
||||||
process: spawn(lintBin, ["--lsp"], {
|
return {
|
||||||
cwd: root,
|
process: spawn(lintBin, ["--lsp"], {
|
||||||
}),
|
cwd: root,
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,8 +376,7 @@ export namespace LSPServer {
|
|||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
|
|
||||||
log.info("installing gopls")
|
log.info("installing gopls")
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
|
||||||
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
|
|
||||||
env: { ...process.env, GOBIN: Global.Path.bin },
|
env: { ...process.env, GOBIN: Global.Path.bin },
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
@@ -414,8 +417,7 @@ export namespace LSPServer {
|
|||||||
}
|
}
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("installing rubocop")
|
log.info("installing rubocop")
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
|
||||||
cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
|
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
@@ -513,7 +515,7 @@ export namespace LSPServer {
|
|||||||
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -746,8 +748,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("installing csharp-ls via dotnet tool")
|
log.info("installing csharp-ls via dotnet tool")
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
|
||||||
cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
|
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
stdin: "pipe",
|
stdin: "pipe",
|
||||||
@@ -786,8 +787,7 @@ export namespace LSPServer {
|
|||||||
|
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
||||||
log.info("installing fsautocomplete via dotnet tool")
|
log.info("installing fsautocomplete via dotnet tool")
|
||||||
const proc = Bun.spawn({
|
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
|
||||||
cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
|
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
stdin: "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")
|
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.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")
|
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -1339,7 +1339,7 @@ export namespace LSPServer {
|
|||||||
const exists = await Filesystem.exists(js)
|
const exists = await Filesystem.exists(js)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -1518,7 +1518,7 @@ export namespace LSPServer {
|
|||||||
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.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")
|
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.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")
|
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
|
||||||
if (!(await Filesystem.exists(js))) {
|
if (!(await Filesystem.exists(js))) {
|
||||||
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
|
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,
|
cwd: Global.Path.bin,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { text } from "node:stream/consumers"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
|
import { Process } from "../util/process"
|
||||||
|
|
||||||
import DESCRIPTION from "./grep.txt"
|
import DESCRIPTION from "./grep.txt"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
@@ -44,14 +46,18 @@ export const GrepTool = Tool.define("grep", {
|
|||||||
}
|
}
|
||||||
args.push(searchPath)
|
args.push(searchPath)
|
||||||
|
|
||||||
const proc = Bun.spawn([rgPath, ...args], {
|
const proc = Process.spawn([rgPath, ...args], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
signal: ctx.abort,
|
abort: ctx.abort,
|
||||||
})
|
})
|
||||||
|
|
||||||
const output = await new Response(proc.stdout).text()
|
if (!proc.stdout || !proc.stderr) {
|
||||||
const errorOutput = await new Response(proc.stderr).text()
|
throw new Error("Process output not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await text(proc.stdout)
|
||||||
|
const errorOutput = await text(proc.stderr)
|
||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
|
|
||||||
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
import { buffer } from "node:stream/consumers"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
import { Process } from "./process"
|
||||||
|
|
||||||
export interface GitResult {
|
export interface GitResult {
|
||||||
exitCode: number
|
exitCode: number
|
||||||
@@ -14,12 +16,12 @@ export interface GitResult {
|
|||||||
* Uses Bun's lightweight `$` shell by default. When the process is running
|
* 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
|
* 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
|
* 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<string, string> }): Promise<GitResult> {
|
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
|
||||||
if (Flag.OPENCODE_CLIENT === "acp") {
|
if (Flag.OPENCODE_CLIENT === "acp") {
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(["git", ...args], {
|
const proc = Process.spawn(["git", ...args], {
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
@@ -27,18 +29,15 @@ export async function git(args: string[], opts: { cwd: string; env?: Record<stri
|
|||||||
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
||||||
})
|
})
|
||||||
// Read output concurrently with exit to avoid pipe buffer deadlock
|
// Read output concurrently with exit to avoid pipe buffer deadlock
|
||||||
const [exitCode, stdout, stderr] = await Promise.all([
|
if (!proc.stdout || !proc.stderr) {
|
||||||
proc.exited,
|
throw new Error("Process output not available")
|
||||||
new Response(proc.stdout).arrayBuffer(),
|
}
|
||||||
new Response(proc.stderr).arrayBuffer(),
|
const [exitCode, out, err] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
|
||||||
])
|
|
||||||
const stdoutBuf = Buffer.from(stdout)
|
|
||||||
const stderrBuf = Buffer.from(stderr)
|
|
||||||
return {
|
return {
|
||||||
exitCode,
|
exitCode,
|
||||||
text: () => stdoutBuf.toString(),
|
text: () => out.toString(),
|
||||||
stdout: stdoutBuf,
|
stdout: out,
|
||||||
stderr: stderrBuf,
|
stderr: err,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
|
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
|
||||||
|
|||||||
71
packages/opencode/src/util/process.ts
Normal file
71
packages/opencode/src/util/process.ts
Normal file
@@ -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<number> }
|
||||||
|
|
||||||
|
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<typeof setTimeout> | 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<number>((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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user