Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 82b13ca184 effect(util): make Process.run/text/lines return Effect
Migrate the run/text/lines helpers in util/process.ts to Effect-returning
functions that yield ChildProcessSpawner and use ChildProcess.make
internally. The legacy Promise-based behaviour stays available under
runPromise/textPromise/linesPromise for non-Effect callers; these are
thin wrappers over the original spawn() path so AbortSignal and timeout
semantics are preserved.

server.ts now runs Process.run/Process.text through a private
ManagedRuntime backed by CrossSpawnSpawner.defaultLayer and the shared
memoMap, since LSP.spawn callbacks execute inside Effect.promise blocks.

Other Process.run/text/lines call sites are renamed to the *Promise
variants and otherwise left untouched for follow-up migrations.
2026-05-12 16:31:38 -04:00
14 changed files with 173 additions and 68 deletions
+3 -3
View File
@@ -29,14 +29,14 @@ export const PrCommand = effectCmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
const checkout = yield* Effect.promise(() =>
Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
Process.runPromise(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }),
)
if (checkout.code !== 0) {
return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
}
const prInfoResult = yield* Effect.promise(() =>
Process.text(
Process.textPromise(
[
"gh",
"pr",
@@ -80,7 +80,7 @@ export const PrCommand = effectCmd({
UI.println(`Importing session...`)
const importResult = yield* Effect.promise(() =>
Process.text(["opencode", "import", sessionUrl], { nothrow: true }),
Process.textPromise(["opencode", "import", sessionUrl], { nothrow: true }),
)
if (importResult.code === 0) {
const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/)
@@ -50,7 +50,7 @@ export async function read(): Promise<Content | undefined> {
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await Process.run(
await Process.runPromise(
[
"osascript",
"-e",
@@ -79,7 +79,7 @@ export async function read(): Promise<Content | undefined> {
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 Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
const base64 = await Process.textPromise(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
@@ -91,11 +91,11 @@ export async function read(): Promise<Content | undefined> {
}
if (os === "linux") {
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
const wayland = await Process.runPromise(["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 Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
const x11 = await Process.runPromise(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
@@ -118,7 +118,7 @@ const getCopyMethod = lazy(async () => {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
await Process.runPromise(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
+1 -1
View File
@@ -192,7 +192,7 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
const result = await Process.runPromise(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
nothrow: true,
})
if (result.code !== 0) {
+1 -1
View File
@@ -56,7 +56,7 @@ export async function readManagedPreferences() {
for (const plist of paths) {
if (!existsSync(plist)) continue
log.info("reading macOS managed preferences", { path: plist })
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
const result = await Process.runPromise(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
if (result.code !== 0) {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
+2 -2
View File
@@ -221,7 +221,7 @@ export const rlang: Info = {
const air = which("air")
if (air == null) return false
const output = await Process.text([air, "--help"], { nothrow: true })
const output = await Process.textPromise([air, "--help"], { nothrow: true })
// Check for "Air: An R language server and formatter"
const firstLine = output.text.split("\n")[0]
@@ -239,7 +239,7 @@ export const uvformat: Info = {
if (await ruff.enabled(context)) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
const output = await Process.runPromise([uv, "format", "--help"], { nothrow: true })
if (output.code === 0) return [uv, "format", "--", "$FILE"]
return false
},
+1 -1
View File
@@ -47,7 +47,7 @@ export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
const cmd = SUPPORTED_IDES.find((i) => i.name === ide)?.cmd
if (!cmd) throw new Error(`Unknown IDE: ${ide}`)
const p = await Process.run([cmd, "--install-extension", "sst-dev.opencode"], {
const p = await Process.runPromise([cmd, "--install-extension", "sst-dev.opencode"], {
nothrow: true,
})
const stdout = p.stdout.toString()
+27 -7
View File
@@ -5,6 +5,9 @@ import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Effect, ManagedRuntime } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Filesystem } from "@/util/filesystem"
import type { InstanceContext } from "../project/instance"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -21,8 +24,17 @@ const pathExists = async (p: string) =>
.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 })
// Private runtime so the Effect-returning `Process.run` / `Process.text` can be
// invoked from this file's promise-based spawn callbacks. The `LSP` service
// layer in `lsp.ts` calls these spawn functions inside `Effect.promise(async
// () => ...)`, so a re-entry point is needed. Sharing `memoMap` keeps a single
// `ChildProcessSpawner` instance across the process.
const processRuntime = ManagedRuntime.make(CrossSpawnSpawner.defaultLayer, { memoMap })
const run = (cmd: string[], opts: Process.RunOptions = {}) =>
processRuntime.runPromise(Process.run(cmd, { ...opts, nothrow: true }))
const output = (cmd: string[], opts: Process.RunOptions = {}) =>
processRuntime.runPromise(Process.text(cmd, { ...opts, nothrow: true }))
export interface Handle {
process: ChildProcessWithoutNullStreams
@@ -188,8 +200,12 @@ export const ESLint: Info = {
await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
await processRuntime.runPromise(
Effect.gen(function* () {
yield* Process.run([npmCmd, "install"], { cwd: finalPath })
yield* Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
}),
)
log.info("installed VS Code ESLint server", { serverPath })
}
@@ -570,9 +586,13 @@ export const ElixirLS: Info = {
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 })
await processRuntime.runPromise(
Effect.gen(function* () {
yield* Process.run(["mix", "deps.get"], { cwd, env })
yield* Process.run(["mix", "compile"], { cwd, env })
yield* Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
}),
)
log.info(`installed elixir-ls`, {
path: elixirLsPath,
+1 -1
View File
@@ -1908,7 +1908,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const sh = Shell.preferred(cfg.shell)
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
shellMatches.map(async ([, cmd]) => (await Process.textPromise([cmd], { shell: sh, nothrow: true })).text),
),
)
let index = 0
+2 -2
View File
@@ -7,11 +7,11 @@ export async function extractZip(zipPath: string, destDir: string) {
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 Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
await Process.runPromise(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
await Process.runPromise(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
export * as Archive from "./archive"
+119 -34
View File
@@ -1,6 +1,8 @@
import { type ChildProcess } from "child_process"
import { type ChildProcess as NodeChildProcess } from "child_process"
import launch from "cross-spawn"
import { buffer } from "node:stream/consumers"
import { Effect, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { errorMessage } from "./error"
export type Stdio = "inherit" | "pipe" | "ignore"
@@ -53,7 +55,7 @@ export class RunFailedError extends Error {
}
}
export type Child = ChildProcess & { exited: Promise<number> }
export type Child = NodeChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], opts: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
@@ -110,8 +112,104 @@ export function spawn(cmd: string[], opts: Options = {}): Child {
return child
}
export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const proc = spawn(cmd, {
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: NodeChildProcess) {
if (proc.exitCode !== null || proc.signalCode !== null) return
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await runPromise(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
const mergeEnv = (env: NodeJS.ProcessEnv | null | undefined): { env: Record<string, string>; extendEnv: boolean } => {
if (env === null) return { env: {}, extendEnv: false }
if (env === undefined) return { env: {}, extendEnv: true }
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(env)) {
if (v !== undefined) out[k] = v
}
return { env: out, extendEnv: true }
}
export const run = Effect.fn("Process.run")(function* (cmd: string[], opts: RunOptions = {}) {
if (cmd.length === 0) return yield* Effect.die(new Error("Command is required"))
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const { env, extendEnv } = mergeEnv(opts.env)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
env,
extendEnv,
shell: opts.shell,
stdin: opts.stdin ?? "ignore",
stdout: "pipe",
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const [stdoutBytes, stderrBytes, exitCode] = yield* Effect.all(
[Stream.mkUint8Array(handle.stdout), Stream.mkUint8Array(handle.stderr), handle.exitCode],
{ concurrency: 3 },
)
return {
code: exitCode as number,
stdout: Buffer.from(stdoutBytes),
stderr: Buffer.from(stderrBytes),
} satisfies Result
}),
).pipe(
Effect.catch((err) =>
opts.nothrow
? Effect.succeed({
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(errorMessage(err)),
} satisfies Result)
: Effect.die(err),
),
)
if (result.code === 0 || opts.nothrow) return result
return yield* Effect.die(new RunFailedError(cmd, result.code, result.stdout, result.stderr))
})
export const text = Effect.fn("Process.text")(function* (cmd: string[], opts: RunOptions = {}) {
const out = yield* run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
} satisfies TextResult
})
export const lines = Effect.fn("Process.lines")(function* (cmd: string[], opts: RunOptions = {}) {
const out = yield* text(cmd, opts)
return out.text.split(/\r?\n/).filter(Boolean)
})
// ---------------------------------------------------------------------------
// Promise-returning facades for legacy non-Effect callers.
//
// The new `run` / `text` / `lines` exports above return Effects. These
// wrappers preserve the original Promise-based shape (failing with
// `RunFailedError` on non-zero exit, etc.) and the legacy AbortSignal /
// timeout semantics by using `spawn(...)` directly.
//
// New code should yield the Effect versions. These wrappers exist only to
// avoid touching the remaining non-Effect call sites in this PR.
// ---------------------------------------------------------------------------
export function runPromise(cmd: string[], opts: RunOptions = {}): Promise<Result> {
const spawnOpts = {
cwd: opts.cwd,
env: opts.env,
stdin: opts.stdin,
@@ -119,13 +217,16 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
abort: opts.abort,
kill: opts.kill,
timeout: opts.timeout,
stdout: "pipe",
stderr: "pipe",
})
stdout: "pipe" as const,
stderr: "pipe" as const,
}
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
// Preserve the legacy abort/timeout semantics by using `spawn(...)` directly
// rather than the Effect path (which lacks AbortSignal hooks today).
const proc = spawn(cmd, spawnOpts)
if (!proc.stdout || !proc.stderr) return Promise.reject(new Error("Process output not available"))
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
@@ -137,40 +238,24 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<Result>
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(errorMessage(err)),
}
} satisfies Result
})
.then((out) => {
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
// Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
// `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: ChildProcess) {
if (proc.exitCode !== null || proc.signalCode !== null) return
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
}
const out = await run(["taskkill", "/pid", String(proc.pid), "/T", "/F"], {
nothrow: true,
})
if (out.code === 0) return
proc.kill()
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
export async function textPromise(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await runPromise(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
export async function linesPromise(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await textPromise(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
export * as Process from "./process"
@@ -17,7 +17,7 @@ type Msg = {
}
function run(msg: Msg) {
return Process.run([process.execPath, worker, JSON.stringify(msg)], {
return Process.runPromise([process.execPath, worker, JSON.stringify(msg)], {
cwd: root,
nothrow: true,
})
+1 -1
View File
@@ -12,7 +12,7 @@ const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
function run(input: { file: string; spec: string; target: string; id: string }) {
return Process.run([process.execPath, worker, JSON.stringify(input)], {
return Process.runPromise([process.execPath, worker, JSON.stringify(input)], {
cwd: root,
nothrow: true,
})
@@ -224,7 +224,7 @@ describe("Truncate", () => {
)
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
const out = await Process.runPromise([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
cwd: ROOT,
})
+8 -8
View File
@@ -10,19 +10,19 @@ function node(script: string) {
describe("util.process", () => {
test("captures stdout and stderr", async () => {
const out = await Process.run(node('process.stdout.write("out");process.stderr.write("err")'))
const out = await Process.runPromise(node('process.stdout.write("out");process.stderr.write("err")'))
expect(out.code).toBe(0)
expect(out.stdout.toString()).toBe("out")
expect(out.stderr.toString()).toBe("err")
})
test("returns code when nothrow is enabled", async () => {
const out = await Process.run(node("process.exit(7)"), { nothrow: true })
const out = await Process.runPromise(node("process.exit(7)"), { nothrow: true })
expect(out.code).toBe(7)
})
test("throws RunFailedError on non-zero exit", async () => {
const err = await Process.run(node('process.stderr.write("bad");process.exit(3)')).catch((error) => error)
const err = await Process.runPromise(node('process.stderr.write("bad");process.exit(3)')).catch((error) => error)
expect(err).toBeInstanceOf(Process.RunFailedError)
if (!(err instanceof Process.RunFailedError)) throw err
expect(err.code).toBe(3)
@@ -34,7 +34,7 @@ describe("util.process", () => {
const started = Date.now()
setTimeout(() => abort.abort(), 25)
const out = await Process.run(node("setInterval(() => {}, 1000)"), {
const out = await Process.runPromise(node("setInterval(() => {}, 1000)"), {
abort: abort.signal,
nothrow: true,
})
@@ -50,7 +50,7 @@ describe("util.process", () => {
const started = Date.now()
setTimeout(() => abort.abort(), 25)
const out = await Process.run(node('process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'), {
const out = await Process.runPromise(node('process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'), {
abort: abort.signal,
nothrow: true,
timeout: 25,
@@ -62,14 +62,14 @@ describe("util.process", () => {
test("uses cwd when spawning commands", async () => {
await using tmp = await tmpdir()
const out = await Process.run(node("process.stdout.write(process.cwd())"), {
const out = await Process.runPromise(node("process.stdout.write(process.cwd())"), {
cwd: tmp.path,
})
expect(out.stdout.toString()).toBe(tmp.path)
})
test("merges environment overrides", async () => {
const out = await Process.run(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
const out = await Process.runPromise(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
env: {
OPENCODE_TEST: "set",
},
@@ -80,7 +80,7 @@ describe("util.process", () => {
test("uses shell in run on Windows", async () => {
if (process.platform !== "win32") return
const out = await Process.run(["set", "OPENCODE_TEST_SHELL"], {
const out = await Process.runPromise(["set", "OPENCODE_TEST_SHELL"], {
shell: true,
env: {
OPENCODE_TEST_SHELL: "ok",