From 3eee2f6afa5c7fde20d0e838143832b681795d9f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 25 Apr 2026 14:22:49 -0400 Subject: core: move cross-spawn-spawner from opencode to core package Moved the cross-spawn-spawner module from packages/opencode to packages/core to enable code sharing across the monorepo. This consolidates the process spawning infrastructure into the core package so other packages can use cross-platform child process spawning without duplicating the implementation. Updated all import statements across the codebase to reference the new location (@opencode-ai/core/effect/cross-spawn-spawner). Removed the local copy from the opencode package along with its tests. --- packages/core/package.json | 6 +- packages/core/src/effect/cross-spawn-spawner.ts | 505 +++++++++++++++++++++ .../core/test/effect/cross-spawn-spawner.test.ts | 423 +++++++++++++++++ .../opencode/src/effect/cross-spawn-spawner.ts | 505 --------------------- packages/opencode/src/file/ripgrep.ts | 2 +- packages/opencode/src/format/index.ts | 2 +- packages/opencode/src/git/index.ts | 2 +- packages/opencode/src/installation/index.ts | 2 +- packages/opencode/src/mcp/index.ts | 2 +- packages/opencode/src/npm/index.ts | 2 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/snapshot/index.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/worktree/index.ts | 2 +- packages/opencode/test/auth/auth.test.ts | 2 +- packages/opencode/test/bus/bus-effect.test.ts | 2 +- packages/opencode/test/config/config.test.ts | 2 +- .../test/effect/cross-spawn-spawner.test.ts | 413 ----------------- packages/opencode/test/format/format.test.ts | 2 +- packages/opencode/test/lsp/index.test.ts | 2 +- packages/opencode/test/lsp/lifecycle.test.ts | 2 +- packages/opencode/test/permission/next.test.ts | 2 +- packages/opencode/test/project/project.test.ts | 2 +- .../opencode/test/project/worktree-remove.test.ts | 2 +- packages/opencode/test/project/worktree.test.ts | 2 +- packages/opencode/test/session/compaction.test.ts | 2 +- .../opencode/test/session/processor-effect.test.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 2 +- .../opencode/test/session/revert-compact.test.ts | 2 +- .../test/session/snapshot-tool-race.test.ts | 2 +- packages/opencode/test/share/share-next.test.ts | 2 +- packages/opencode/test/skill/skill.test.ts | 2 +- packages/opencode/test/storage/storage.test.ts | 2 +- packages/opencode/test/tool/bash.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 2 +- packages/opencode/test/tool/grep.test.ts | 2 +- packages/opencode/test/tool/lsp.test.ts | 2 +- packages/opencode/test/tool/question.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 2 +- packages/opencode/test/tool/registry.test.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 2 +- packages/opencode/test/tool/task.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 2 +- 44 files changed, 969 insertions(+), 961 deletions(-) create mode 100644 packages/core/src/effect/cross-spawn-spawner.ts create mode 100644 packages/core/test/effect/cross-spawn-spawner.test.ts delete mode 100644 packages/opencode/src/effect/cross-spawn-spawner.ts delete mode 100644 packages/opencode/test/effect/cross-spawn-spawner.test.ts (limited to 'packages') diff --git a/packages/core/package.json b/packages/core/package.json index a244ea8b4..bd826de35 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -18,23 +18,21 @@ "imports": {}, "devDependencies": { "@tsconfig/bun": "catalog:", - "@types/semver": "catalog:", "@types/bun": "catalog:", - "@types/npmcli__arborist": "6.3.3" + "@types/cross-spawn": "catalog:" }, "dependencies": { "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", - "@npmcli/arborist": "catalog:", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "effect": "catalog:", + "cross-spawn": "catalog:", "glob": "13.0.5", "mime-types": "3.0.2", "minimatch": "10.2.5", - "semver": "catalog:", "xdg-basedir": "5.1.0", "zod": "catalog:" }, diff --git a/packages/core/src/effect/cross-spawn-spawner.ts b/packages/core/src/effect/cross-spawn-spawner.ts new file mode 100644 index 000000000..ad8d4126d --- /dev/null +++ b/packages/core/src/effect/cross-spawn-spawner.ts @@ -0,0 +1,505 @@ +import type * as Arr from "effect/Array" +import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" +import * as NodePath from "@effect/platform-node/NodePath" +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import * as FileSystem from "effect/FileSystem" +import * as Layer from "effect/Layer" +import * as Path from "effect/Path" +import * as PlatformError from "effect/PlatformError" +import * as Predicate from "effect/Predicate" +import type * as Scope from "effect/Scope" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" +import * as ChildProcess from "effect/unstable/process/ChildProcess" +import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" +import { + ChildProcessSpawner, + ExitCode, + make as makeSpawner, + makeHandle, + ProcessId, +} from "effect/unstable/process/ChildProcessSpawner" +import * as NodeChildProcess from "node:child_process" +import { PassThrough } from "node:stream" +import launch from "cross-spawn" + +const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) + +const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => { + switch (err.code) { + case "ENOENT": + return "NotFound" + case "EACCES": + return "PermissionDenied" + case "EEXIST": + return "AlreadyExists" + case "EISDIR": + return "BadResource" + case "ENOTDIR": + return "BadResource" + case "EBUSY": + return "Busy" + case "ELOOP": + return "BadResource" + default: + return "Unknown" + } +} + +const flatten = (command: ChildProcess.Command) => { + const commands: Array = [] + const opts: Array = [] + + const walk = (cmd: ChildProcess.Command): void => { + switch (cmd._tag) { + case "StandardCommand": + commands.push(cmd) + return + case "PipedCommand": + walk(cmd.left) + opts.push(cmd.options) + walk(cmd.right) + return + } + } + + walk(command) + if (commands.length === 0) throw new Error("flatten produced empty commands array") + const [head, ...tail] = commands + return { + commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, + opts, + } +} + +const toPlatformError = ( + method: string, + err: NodeJS.ErrnoException, + command: ChildProcess.Command, +): PlatformError.PlatformError => { + const cmd = flatten(command) + .commands.map((x) => `${x.command} ${x.args.join(" ")}`) + .join(" | ") + return PlatformError.systemError({ + _tag: toTag(err), + module: "ChildProcess", + method, + pathOrDescriptor: cmd, + syscall: err.syscall, + cause: err, + }) +} + +type ExitSignal = Deferred.Deferred + +export const make = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) { + if (Predicate.isUndefined(opts.cwd)) return undefined + yield* fs.access(opts.cwd) + return path.resolve(opts.cwd) + }) + + const env = (opts: ChildProcess.CommandOptions) => + opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env + + const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined => + Stream.isStream(x) ? "pipe" : x + + const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined => + Sink.isSink(x) ? "pipe" : x + + const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => { + const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true } + if (Predicate.isUndefined(opts.stdin)) return cfg + if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin } + if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin } + return { + stream: opts.stdin.stream, + encoding: opts.stdin.encoding ?? cfg.encoding, + endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone, + } + } + + const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => { + const cfg = opts[key] + if (Predicate.isUndefined(cfg)) return { stream: "pipe" } + if (typeof cfg === "string") return { stream: cfg } + if (Sink.isSink(cfg)) return { stream: cfg } + return { stream: cfg.stream } + } + + const fds = (opts: ChildProcess.CommandOptions) => { + if (Predicate.isUndefined(opts.additionalFds)) return [] + return Object.entries(opts.additionalFds) + .flatMap(([name, config]) => { + const fd = ChildProcess.parseFdName(name) + return Predicate.isUndefined(fd) ? [] : [{ fd, config }] + }) + .toSorted((a, b) => a.fd - b.fd) + } + + const stdios = ( + sin: ChildProcess.StdinConfig, + sout: ChildProcess.StdoutConfig, + serr: ChildProcess.StderrConfig, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ): NodeChildProcess.StdioOptions => { + const pipe = (x: NodeChildProcess.IOType | undefined) => + process.platform === "win32" && x === "pipe" ? "overlapped" : x + const arr: Array = [ + pipe(input(sin.stream)), + pipe(output(sout.stream)), + pipe(output(serr.stream)), + ] + if (extra.length === 0) return arr as NodeChildProcess.StdioOptions + const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2) + for (let i = 3; i <= max; i++) arr[i] = "ignore" + for (const x of extra) arr[x.fd] = pipe("pipe") + return arr as NodeChildProcess.StdioOptions + } + + const setupFds = Effect.fnUntraced(function* ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, + ) { + if (extra.length === 0) { + return { + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + } + } + + const ins = new Map>() + const outs = new Map>() + + for (const x of extra) { + const node = proc.stdio[x.fd] + switch (x.config.type) { + case "input": { + let sink: Sink.Sink = Sink.drain + if (node && "write" in node) { + sink = NodeSink.fromWritable({ + evaluate: () => node, + onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command), + endOnDone: true, + }) + } + if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink)) + ins.set(x.fd, sink) + break + } + case "output": { + let stream: Stream.Stream = Stream.empty + if (node && "read" in node) { + const tap = new PassThrough() + node.on("error", (err) => tap.destroy(toError(err))) + node.pipe(tap) + stream = NodeStream.fromReadable({ + evaluate: () => tap, + onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command), + }) + } + if (x.config.sink) stream = Stream.transduce(stream, x.config.sink) + outs.set(x.fd, stream) + break + } + } + } + + return { + getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain, + getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty, + } + }) + + const setupStdin = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + cfg: ChildProcess.StdinConfig, + ) => + Effect.suspend(() => { + let sink: Sink.Sink = Sink.drain + if (Predicate.isNotNull(proc.stdin)) { + sink = NodeSink.fromWritable({ + evaluate: () => proc.stdin!, + onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command), + endOnDone: cfg.endOnDone, + encoding: cfg.encoding, + }) + } + if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink) + return Effect.succeed(sink) + }) + + const setupOutput = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + out: ChildProcess.StdoutConfig, + err: ChildProcess.StderrConfig, + ) => { + let stdout = proc.stdout + ? NodeStream.fromReadable({ + evaluate: () => proc.stdout!, + onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command), + }) + : Stream.empty + let stderr = proc.stderr + ? NodeStream.fromReadable({ + evaluate: () => proc.stderr!, + onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command), + }) + : Stream.empty + + if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream) + if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream) + + return { stdout, stderr, all: Stream.merge(stdout, stderr) } + } + + const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => + Effect.callback((resume) => { + const signal = Deferred.makeUnsafe() + const proc = launch(command.command, command.args, opts) + let end = false + let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined + proc.on("error", (err) => { + resume(Effect.fail(toPlatformError("spawn", err, command))) + }) + proc.on("exit", (...args) => { + exit = args + }) + proc.on("close", (...args) => { + if (end) return + end = true + Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) + }) + proc.on("spawn", () => { + resume(Effect.succeed([proc, signal])) + }) + return Effect.sync(() => { + proc.kill("SIGTERM") + }) + }) + + const killGroup = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => { + if (globalThis.process.platform === "win32") { + return Effect.callback((resume) => { + NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => { + if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command))) + resume(Effect.void) + }) + }) + } + + return Effect.try({ + try: () => { + globalThis.process.kill(-proc.pid!, signal) + }, + catch: (err) => toPlatformError("kill", toError(err), command), + }) + } + + const killOne = ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => + Effect.suspend(() => { + if (proc.kill(signal)) return Effect.void + return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) + }) + + const timeout = + ( + proc: NodeChildProcess.ChildProcess, + command: ChildProcess.StandardCommand, + opts: ChildProcess.KillOptions | undefined, + ) => + ( + f: ( + command: ChildProcess.StandardCommand, + proc: NodeChildProcess.ChildProcess, + signal: NodeJS.Signals, + ) => Effect.Effect, + ) => { + const signal = opts?.killSignal ?? "SIGTERM" + if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal) + return Effect.timeoutOrElse(f(command, proc, signal), { + duration: opts.forceKillAfter, + orElse: () => f(command, proc, "SIGKILL"), + }) + } + + const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => { + const opt = from ?? "stdout" + switch (opt) { + case "stdout": + return handle.stdout + case "stderr": + return handle.stderr + case "all": + return handle.all + default: { + const fd = ChildProcess.parseFdName(opt) + return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout + } + } + } + + const spawnCommand: ( + command: ChildProcess.Command, + ) => Effect.Effect = Effect.fnUntraced( + function* (command) { + switch (command._tag) { + case "StandardCommand": { + const sin = stdin(command.options) + const sout = stdio(command.options, "stdout") + const serr = stdio(command.options, "stderr") + const extra = fds(command.options) + const dir = yield* cwd(command.options) + + const [proc, signal] = yield* Effect.acquireRelease( + spawn(command, { + cwd: dir, + env: env(command.options), + stdio: stdios(sin, sout, serr, extra), + detached: command.options.detached ?? process.platform !== "win32", + shell: command.options.shell, + windowsHide: process.platform === "win32", + }), + Effect.fnUntraced(function* ([proc, signal]) { + const done = yield* Deferred.isDone(signal) + const kill = timeout(proc, command, command.options) + if (done) { + const [code] = yield* Deferred.await(signal) + if (process.platform === "win32") return yield* Effect.void + if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) + return yield* Effect.void + } + const send = (s: NodeJS.Signals) => + Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s)) + const sig = command.options.killSignal ?? "SIGTERM" + const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid) + const escalated = command.options.forceKillAfter + ? Effect.timeoutOrElse(attempt, { + duration: command.options.forceKillAfter, + orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + }) + : attempt + return yield* Effect.ignore(escalated) + }), + ) + + const fd = yield* setupFds(command, proc, extra) + const out = setupOutput(command, proc, sout, serr) + let ref = true + return makeHandle({ + pid: ProcessId(proc.pid!), + stdin: yield* setupStdin(command, proc, sin), + stdout: out.stdout, + stderr: out.stderr, + all: out.all, + getInputFd: fd.getInputFd, + getOutputFd: fd.getOutputFd, + isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), + exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { + if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) + return Effect.fail( + toPlatformError( + "exitCode", + new Error(`Process interrupted due to receipt of signal: '${signal}'`), + command, + ), + ) + }), + kill: (opts?: ChildProcess.KillOptions) => { + const sig = opts?.killSignal ?? "SIGTERM" + const send = (s: NodeJS.Signals) => + Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s)) + const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid) + if (!opts?.forceKillAfter) return attempt + return Effect.timeoutOrElse(attempt, { + duration: opts.forceKillAfter, + orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), + }) + }, + unref: Effect.sync(() => { + if (ref) { + proc.unref() + ref = false + } + return Effect.sync(() => { + if (!ref) { + proc.ref() + ref = true + } + }) + }), + }) + } + case "PipedCommand": { + const flat = flatten(command) + const [head, ...tail] = flat.commands + let handle = spawnCommand(head) + for (let i = 0; i < tail.length; i++) { + const next = tail[i] + const opts = flat.opts[i] ?? {} + const sin = stdin(next.options) + const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from))) + const to = opts.to ?? "stdin" + if (to === "stdin") { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + const fd = ChildProcess.parseFdName(to) + if (Predicate.isUndefined(fd)) { + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + stdin: { ...sin, stream }, + }), + ) + continue + } + handle = spawnCommand( + ChildProcess.make(next.command, next.args, { + ...next.options, + additionalFds: { + ...next.options.additionalFds, + [ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream }, + }, + }), + ) + } + return yield* handle + } + } + }, + ) + + return makeSpawner(spawnCommand) +}) + +export const layer: Layer.Layer = Layer.effect( + ChildProcessSpawner, + make, +) + +export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) + +export * as CrossSpawnSpawner from "./cross-spawn-spawner" diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts new file mode 100644 index 000000000..d53725797 --- /dev/null +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -0,0 +1,423 @@ +import { describe, expect } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect, Exit, Stream } from "effect" +import type * as PlatformError from "effect/PlatformError" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" +import { testEffect } from "../lib/effect" + +const live = CrossSpawnSpawner.defaultLayer +const fx = testEffect(live) + +function js(code: string, opts?: ChildProcess.CommandOptions) { + return ChildProcess.make("node", ["-e", code], opts) +} + +function decodeByteStream(stream: Stream.Stream) { + return Stream.runCollect(stream).pipe( + Effect.map((chunks) => { + const total = chunks.reduce((acc, x) => acc + x.length, 0) + const out = new Uint8Array(total) + let off = 0 + for (const chunk of chunks) { + out.set(chunk, off) + off += chunk.length + } + return new TextDecoder("utf-8").decode(out).trim() + }), + ) +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function tmpdir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-test-")) + return { + path: dir, + async [Symbol.asyncDispose]() { + await fs.rm(dir, { recursive: true, force: true }) + }, + } +} + +async function gone(pid: number, timeout = 5_000) { + const end = Date.now() + timeout + while (Date.now() < end) { + if (!alive(pid)) return true + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return !alive(pid) +} + +describe("cross-spawn spawner", () => { + describe("basic spawning", () => { + fx.effect( + "captures stdout", + Effect.gen(function* () { + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), + ) + expect(out).toBe("ok") + }), + ) + + fx.effect( + "captures multiple lines", + Effect.gen(function* () { + const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("line1\nline2\nline3") + }), + ) + + fx.effect( + "returns exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(0)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + + fx.effect( + "returns non-zero exit code", + Effect.gen(function* () { + const handle = yield* js("process.exit(42)") + const code = yield* handle.exitCode + expect(code).toBe(ChildProcessSpawner.ExitCode(42)) + }), + ) + }) + + describe("cwd option", () => { + fx.effect( + "uses cwd when spawning commands", + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), + ), + ) + expect(out).toBe(tmp.path) + }), + ) + + fx.effect( + "fails for invalid cwd", + Effect.gen(function* () { + const exit = yield* Effect.exit( + ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), + ) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + describe("env option", () => { + fx.effect( + "passes environment variables with extendEnv", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { + env: { TEST_VAR: "test_value" }, + extendEnv: true, + }) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("test_value") + }), + ) + + fx.effect( + "passes multiple environment variables", + Effect.gen(function* () { + const handle = yield* js( + "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", + { + env: { VAR1: "one", VAR2: "two", VAR3: "three" }, + extendEnv: true, + }, + ) + const out = yield* decodeByteStream(handle.stdout) + expect(out).toBe("one-two-three") + }), + ) + }) + + describe("stderr", () => { + fx.effect( + "captures stderr output", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error message")') + const err = yield* decodeByteStream(handle.stderr) + expect(err).toBe("error message") + }), + ) + + fx.effect( + "captures both stdout and stderr", + Effect.gen(function* () { + const handle = yield* js( + [ + "let pending = 2", + "const done = () => {", + " pending -= 1", + " if (pending === 0) setTimeout(() => process.exit(0), 0)", + "}", + 'process.stdout.write("stdout\\n", done)', + 'process.stderr.write("stderr\\n", done)', + ].join("\n"), + ) + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { + concurrency: 2, + }) + expect(stdout).toBe("stdout") + expect(stderr).toBe("stderr") + }), + ) + }) + + describe("combined output (all)", () => { + fx.effect( + "captures stdout via .all when no stderr", + Effect.gen(function* () { + const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stdout") + }), + ) + + fx.effect( + "captures stderr via .all when no stdout", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("hello from stderr")') + const all = yield* decodeByteStream(handle.all) + expect(all).toBe("hello from stderr") + }), + ) + }) + + describe("stdin", () => { + fx.effect( + "allows providing standard input to a command", + Effect.gen(function* () { + const input = "a b c" + const stdin = Stream.make(Buffer.from(input, "utf-8")) + const handle = yield* js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + { stdin }, + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("a b c") + }), + ) + }) + + describe("process control", () => { + fx.effect( + "kills a running process", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js("setTimeout(() => {}, 10_000)") + yield* handle.kill() + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "kills a child when scope exits", + Effect.gen(function* () { + const pid = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* js("setInterval(() => {}, 10_000)") + return Number(handle.pid) + }), + ) + const done = yield* Effect.promise(() => gone(pid)) + expect(done).toBe(true) + }), + ) + + fx.effect( + "forceKillAfter escalates for stubborn processes", + Effect.gen(function* () { + if (process.platform === "win32") return + + const started = Date.now() + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') + yield* handle.kill({ forceKillAfter: 100 }) + return yield* handle.exitCode + }), + ) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + + fx.effect( + "isRunning reflects process state", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("done")') + yield* handle.exitCode + const running = yield* handle.isRunning + expect(running).toBe(false) + }), + ) + }) + + describe("error handling", () => { + fx.effect( + "fails for invalid command", + Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const handle = yield* ChildProcess.make("nonexistent-command-12345") + return yield* handle.exitCode + }), + ) + expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) + }), + ) + }) + + describe("pipeline", () => { + fx.effect( + "pipes stdout of one command to stdin of another", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO WORLD") + }), + ) + + fx.effect( + "three-stage pipeline", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("hello world")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', + ), + ), + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', + ), + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("HELLO-WORLD") + }), + ) + + fx.effect( + "pipes stderr with { from: 'stderr' }", + Effect.gen(function* () { + const handle = yield* js('process.stderr.write("error")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "stderr" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toBe("error") + }), + ) + + fx.effect( + "pipes combined output with { from: 'all' }", + Effect.gen(function* () { + const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( + ChildProcess.pipeTo( + js( + 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', + ), + { from: "all" }, + ), + ) + const out = yield* decodeByteStream(handle.stdout) + yield* handle.exitCode + expect(out).toContain("stdout") + expect(out).toContain("stderr") + }), + ) + }) + + describe("Windows-specific", () => { + fx.effect( + "uses shell routing on Windows", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { + shell: true, + extendEnv: true, + env: { OPENCODE_TEST_SHELL: "ok" }, + }), + ), + ) + expect(out).toContain("OPENCODE_TEST_SHELL=ok") + }), + ) + + fx.effect( + "runs cmd scripts with spaces on Windows without shell", + Effect.gen(function* () { + if (process.platform !== "win32") return + + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + const dir = path.join(tmp.path, "with space") + const file = path.join(dir, "echo cmd.cmd") + + yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) + yield* Effect.promise(() => fs.writeFile(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) + + const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.exitCode( + ChildProcess.make(file, ["--stdio"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ), + ) + expect(code).toBe(ChildProcessSpawner.ExitCode(0)) + }), + ) + }) +}) diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts deleted file mode 100644 index ad8d4126d..000000000 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ /dev/null @@ -1,505 +0,0 @@ -import type * as Arr from "effect/Array" -import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" -import * as NodePath from "@effect/platform-node/NodePath" -import * as Deferred from "effect/Deferred" -import * as Effect from "effect/Effect" -import * as Exit from "effect/Exit" -import * as FileSystem from "effect/FileSystem" -import * as Layer from "effect/Layer" -import * as Path from "effect/Path" -import * as PlatformError from "effect/PlatformError" -import * as Predicate from "effect/Predicate" -import type * as Scope from "effect/Scope" -import * as Sink from "effect/Sink" -import * as Stream from "effect/Stream" -import * as ChildProcess from "effect/unstable/process/ChildProcess" -import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" -import { - ChildProcessSpawner, - ExitCode, - make as makeSpawner, - makeHandle, - ProcessId, -} from "effect/unstable/process/ChildProcessSpawner" -import * as NodeChildProcess from "node:child_process" -import { PassThrough } from "node:stream" -import launch from "cross-spawn" - -const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) - -const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => { - switch (err.code) { - case "ENOENT": - return "NotFound" - case "EACCES": - return "PermissionDenied" - case "EEXIST": - return "AlreadyExists" - case "EISDIR": - return "BadResource" - case "ENOTDIR": - return "BadResource" - case "EBUSY": - return "Busy" - case "ELOOP": - return "BadResource" - default: - return "Unknown" - } -} - -const flatten = (command: ChildProcess.Command) => { - const commands: Array = [] - const opts: Array = [] - - const walk = (cmd: ChildProcess.Command): void => { - switch (cmd._tag) { - case "StandardCommand": - commands.push(cmd) - return - case "PipedCommand": - walk(cmd.left) - opts.push(cmd.options) - walk(cmd.right) - return - } - } - - walk(command) - if (commands.length === 0) throw new Error("flatten produced empty commands array") - const [head, ...tail] = commands - return { - commands: [head, ...tail] as Arr.NonEmptyReadonlyArray, - opts, - } -} - -const toPlatformError = ( - method: string, - err: NodeJS.ErrnoException, - command: ChildProcess.Command, -): PlatformError.PlatformError => { - const cmd = flatten(command) - .commands.map((x) => `${x.command} ${x.args.join(" ")}`) - .join(" | ") - return PlatformError.systemError({ - _tag: toTag(err), - module: "ChildProcess", - method, - pathOrDescriptor: cmd, - syscall: err.syscall, - cause: err, - }) -} - -type ExitSignal = Deferred.Deferred - -export const make = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) { - if (Predicate.isUndefined(opts.cwd)) return undefined - yield* fs.access(opts.cwd) - return path.resolve(opts.cwd) - }) - - const env = (opts: ChildProcess.CommandOptions) => - opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env - - const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined => - Stream.isStream(x) ? "pipe" : x - - const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined => - Sink.isSink(x) ? "pipe" : x - - const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => { - const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true } - if (Predicate.isUndefined(opts.stdin)) return cfg - if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin } - if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin } - return { - stream: opts.stdin.stream, - encoding: opts.stdin.encoding ?? cfg.encoding, - endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone, - } - } - - const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => { - const cfg = opts[key] - if (Predicate.isUndefined(cfg)) return { stream: "pipe" } - if (typeof cfg === "string") return { stream: cfg } - if (Sink.isSink(cfg)) return { stream: cfg } - return { stream: cfg.stream } - } - - const fds = (opts: ChildProcess.CommandOptions) => { - if (Predicate.isUndefined(opts.additionalFds)) return [] - return Object.entries(opts.additionalFds) - .flatMap(([name, config]) => { - const fd = ChildProcess.parseFdName(name) - return Predicate.isUndefined(fd) ? [] : [{ fd, config }] - }) - .toSorted((a, b) => a.fd - b.fd) - } - - const stdios = ( - sin: ChildProcess.StdinConfig, - sout: ChildProcess.StdoutConfig, - serr: ChildProcess.StderrConfig, - extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, - ): NodeChildProcess.StdioOptions => { - const pipe = (x: NodeChildProcess.IOType | undefined) => - process.platform === "win32" && x === "pipe" ? "overlapped" : x - const arr: Array = [ - pipe(input(sin.stream)), - pipe(output(sout.stream)), - pipe(output(serr.stream)), - ] - if (extra.length === 0) return arr as NodeChildProcess.StdioOptions - const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2) - for (let i = 3; i <= max; i++) arr[i] = "ignore" - for (const x of extra) arr[x.fd] = pipe("pipe") - return arr as NodeChildProcess.StdioOptions - } - - const setupFds = Effect.fnUntraced(function* ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>, - ) { - if (extra.length === 0) { - return { - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - } - } - - const ins = new Map>() - const outs = new Map>() - - for (const x of extra) { - const node = proc.stdio[x.fd] - switch (x.config.type) { - case "input": { - let sink: Sink.Sink = Sink.drain - if (node && "write" in node) { - sink = NodeSink.fromWritable({ - evaluate: () => node, - onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command), - endOnDone: true, - }) - } - if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink)) - ins.set(x.fd, sink) - break - } - case "output": { - let stream: Stream.Stream = Stream.empty - if (node && "read" in node) { - const tap = new PassThrough() - node.on("error", (err) => tap.destroy(toError(err))) - node.pipe(tap) - stream = NodeStream.fromReadable({ - evaluate: () => tap, - onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command), - }) - } - if (x.config.sink) stream = Stream.transduce(stream, x.config.sink) - outs.set(x.fd, stream) - break - } - } - } - - return { - getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain, - getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty, - } - }) - - const setupStdin = ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - cfg: ChildProcess.StdinConfig, - ) => - Effect.suspend(() => { - let sink: Sink.Sink = Sink.drain - if (Predicate.isNotNull(proc.stdin)) { - sink = NodeSink.fromWritable({ - evaluate: () => proc.stdin!, - onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command), - endOnDone: cfg.endOnDone, - encoding: cfg.encoding, - }) - } - if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink) - return Effect.succeed(sink) - }) - - const setupOutput = ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - out: ChildProcess.StdoutConfig, - err: ChildProcess.StderrConfig, - ) => { - let stdout = proc.stdout - ? NodeStream.fromReadable({ - evaluate: () => proc.stdout!, - onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command), - }) - : Stream.empty - let stderr = proc.stderr - ? NodeStream.fromReadable({ - evaluate: () => proc.stderr!, - onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command), - }) - : Stream.empty - - if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream) - if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream) - - return { stdout, stderr, all: Stream.merge(stdout, stderr) } - } - - const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) => - Effect.callback((resume) => { - const signal = Deferred.makeUnsafe() - const proc = launch(command.command, command.args, opts) - let end = false - let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined - proc.on("error", (err) => { - resume(Effect.fail(toPlatformError("spawn", err, command))) - }) - proc.on("exit", (...args) => { - exit = args - }) - proc.on("close", (...args) => { - if (end) return - end = true - Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args)) - }) - proc.on("spawn", () => { - resume(Effect.succeed([proc, signal])) - }) - return Effect.sync(() => { - proc.kill("SIGTERM") - }) - }) - - const killGroup = ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - signal: NodeJS.Signals, - ) => { - if (globalThis.process.platform === "win32") { - return Effect.callback((resume) => { - NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => { - if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command))) - resume(Effect.void) - }) - }) - } - - return Effect.try({ - try: () => { - globalThis.process.kill(-proc.pid!, signal) - }, - catch: (err) => toPlatformError("kill", toError(err), command), - }) - } - - const killOne = ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - signal: NodeJS.Signals, - ) => - Effect.suspend(() => { - if (proc.kill(signal)) return Effect.void - return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command)) - }) - - const timeout = - ( - proc: NodeChildProcess.ChildProcess, - command: ChildProcess.StandardCommand, - opts: ChildProcess.KillOptions | undefined, - ) => - ( - f: ( - command: ChildProcess.StandardCommand, - proc: NodeChildProcess.ChildProcess, - signal: NodeJS.Signals, - ) => Effect.Effect, - ) => { - const signal = opts?.killSignal ?? "SIGTERM" - if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal) - return Effect.timeoutOrElse(f(command, proc, signal), { - duration: opts.forceKillAfter, - orElse: () => f(command, proc, "SIGKILL"), - }) - } - - const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => { - const opt = from ?? "stdout" - switch (opt) { - case "stdout": - return handle.stdout - case "stderr": - return handle.stderr - case "all": - return handle.all - default: { - const fd = ChildProcess.parseFdName(opt) - return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout - } - } - } - - const spawnCommand: ( - command: ChildProcess.Command, - ) => Effect.Effect = Effect.fnUntraced( - function* (command) { - switch (command._tag) { - case "StandardCommand": { - const sin = stdin(command.options) - const sout = stdio(command.options, "stdout") - const serr = stdio(command.options, "stderr") - const extra = fds(command.options) - const dir = yield* cwd(command.options) - - const [proc, signal] = yield* Effect.acquireRelease( - spawn(command, { - cwd: dir, - env: env(command.options), - stdio: stdios(sin, sout, serr, extra), - detached: command.options.detached ?? process.platform !== "win32", - shell: command.options.shell, - windowsHide: process.platform === "win32", - }), - Effect.fnUntraced(function* ([proc, signal]) { - const done = yield* Deferred.isDone(signal) - const kill = timeout(proc, command, command.options) - if (done) { - const [code] = yield* Deferred.await(signal) - if (process.platform === "win32") return yield* Effect.void - if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) - return yield* Effect.void - } - const send = (s: NodeJS.Signals) => - Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s)) - const sig = command.options.killSignal ?? "SIGTERM" - const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid) - const escalated = command.options.forceKillAfter - ? Effect.timeoutOrElse(attempt, { - duration: command.options.forceKillAfter, - orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), - }) - : attempt - return yield* Effect.ignore(escalated) - }), - ) - - const fd = yield* setupFds(command, proc, extra) - const out = setupOutput(command, proc, sout, serr) - let ref = true - return makeHandle({ - pid: ProcessId(proc.pid!), - stdin: yield* setupStdin(command, proc, sin), - stdout: out.stdout, - stderr: out.stderr, - all: out.all, - getInputFd: fd.getInputFd, - getOutputFd: fd.getOutputFd, - isRunning: Effect.map(Deferred.isDone(signal), (done) => !done), - exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => { - if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code)) - return Effect.fail( - toPlatformError( - "exitCode", - new Error(`Process interrupted due to receipt of signal: '${signal}'`), - command, - ), - ) - }), - kill: (opts?: ChildProcess.KillOptions) => { - const sig = opts?.killSignal ?? "SIGTERM" - const send = (s: NodeJS.Signals) => - Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s)) - const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid) - if (!opts?.forceKillAfter) return attempt - return Effect.timeoutOrElse(attempt, { - duration: opts.forceKillAfter, - orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), - }) - }, - unref: Effect.sync(() => { - if (ref) { - proc.unref() - ref = false - } - return Effect.sync(() => { - if (!ref) { - proc.ref() - ref = true - } - }) - }), - }) - } - case "PipedCommand": { - const flat = flatten(command) - const [head, ...tail] = flat.commands - let handle = spawnCommand(head) - for (let i = 0; i < tail.length; i++) { - const next = tail[i] - const opts = flat.opts[i] ?? {} - const sin = stdin(next.options) - const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from))) - const to = opts.to ?? "stdin" - if (to === "stdin") { - handle = spawnCommand( - ChildProcess.make(next.command, next.args, { - ...next.options, - stdin: { ...sin, stream }, - }), - ) - continue - } - const fd = ChildProcess.parseFdName(to) - if (Predicate.isUndefined(fd)) { - handle = spawnCommand( - ChildProcess.make(next.command, next.args, { - ...next.options, - stdin: { ...sin, stream }, - }), - ) - continue - } - handle = spawnCommand( - ChildProcess.make(next.command, next.args, { - ...next.options, - additionalFds: { - ...next.options.additionalFds, - [ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream }, - }, - }), - ) - } - return yield* handle - } - } - }, - ) - - return makeSpawner(spawnCommand) -}) - -export const layer: Layer.Layer = Layer.effect( - ChildProcessSpawner, - make, -) - -export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) - -export * as CrossSpawnSpawner from "./cross-spawn-spawner" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index ab725b731..dd794ef6f 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -6,7 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Global } from "@opencode-ai/core/global" import { Log } from "@/util" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 4284a2cf6..2c5943c7d 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,6 +1,6 @@ import { Effect, Layer, Context, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { InstanceState } from "@/effect" import path from "path" import { mergeDeep } from "remeda" diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 719b5607f..d2e04910a 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -1,4 +1,4 @@ -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Effect, Layer, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 1a39d3c61..55c4092e2 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,6 @@ import { Effect, Layer, Schema, Context, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 23862db63..9652a1258 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -29,7 +29,7 @@ import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { zod as effectZod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index ca67491d0..23368b29b 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -14,7 +14,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "../effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" export class InstallFailedError extends Schema.TaggedErrorClass()("NpmInstallFailedError", { add: Schema.Array(Schema.String).pipe(Schema.optional), diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index e622464b4..c437fedb2 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -12,7 +12,7 @@ import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effe import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 708961168..87f914a80 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -27,7 +27,7 @@ import { LSP } from "../lsp" import { Flag } from "@opencode-ai/core/flag/flag" import { ulid } from "ulid" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import * as Stream from "effect/Stream" import { Command } from "../command" import { pathToFileURL, fileURLToPath } from "url" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 3701b8210..32d65633c 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -3,7 +3,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" import path from "path" import z from "zod" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Hash } from "@opencode-ai/core/util/hash" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b7fa696c8..422fd8e3a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -34,7 +34,7 @@ import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Ripgrep } from "../file/ripgrep" import { Format } from "../format" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b89ac32a9..f39d9ad04 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -18,7 +18,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { InstanceState } from "@/effect" const log = Log.create({ service: "worktree" }) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 864649d7a..8688eafaf 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 3d602ae6f..d8b4a275b 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -3,7 +3,7 @@ import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8512236a3..bdd361e7a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -13,7 +13,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" import { provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { testEffect } from "../lib/effect" /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts deleted file mode 100644 index b4e52529c..000000000 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { describe, expect } from "bun:test" -import fs from "node:fs/promises" -import path from "node:path" -import { Effect, Exit, Stream } from "effect" -import type * as PlatformError from "effect/PlatformError" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { tmpdir } from "../fixture/fixture" -import { testEffect } from "../lib/effect" - -const live = CrossSpawnSpawner.defaultLayer -const fx = testEffect(live) - -function js(code: string, opts?: ChildProcess.CommandOptions) { - return ChildProcess.make("node", ["-e", code], opts) -} - -function decodeByteStream(stream: Stream.Stream) { - return Stream.runCollect(stream).pipe( - Effect.map((chunks) => { - const total = chunks.reduce((acc, x) => acc + x.length, 0) - const out = new Uint8Array(total) - let off = 0 - for (const chunk of chunks) { - out.set(chunk, off) - off += chunk.length - } - return new TextDecoder("utf-8").decode(out).trim() - }), - ) -} - -function alive(pid: number) { - try { - process.kill(pid, 0) - return true - } catch { - return false - } -} - -async function gone(pid: number, timeout = 5_000) { - const end = Date.now() + timeout - while (Date.now() < end) { - if (!alive(pid)) return true - await Bun.sleep(50) - } - return !alive(pid) -} - -describe("cross-spawn spawner", () => { - describe("basic spawning", () => { - fx.effect( - "captures stdout", - Effect.gen(function* () { - const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => - svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])), - ) - expect(out).toBe("ok") - }), - ) - - fx.effect( - "captures multiple lines", - Effect.gen(function* () { - const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")') - const out = yield* decodeByteStream(handle.stdout) - expect(out).toBe("line1\nline2\nline3") - }), - ) - - fx.effect( - "returns exit code", - Effect.gen(function* () { - const handle = yield* js("process.exit(0)") - const code = yield* handle.exitCode - expect(code).toBe(ChildProcessSpawner.ExitCode(0)) - }), - ) - - fx.effect( - "returns non-zero exit code", - Effect.gen(function* () { - const handle = yield* js("process.exit(42)") - const code = yield* handle.exitCode - expect(code).toBe(ChildProcessSpawner.ExitCode(42)) - }), - ) - }) - - describe("cwd option", () => { - fx.effect( - "uses cwd when spawning commands", - Effect.gen(function* () { - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ) - const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => - svc.string( - ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), - ), - ) - expect(out).toBe(tmp.path) - }), - ) - - fx.effect( - "fails for invalid cwd", - Effect.gen(function* () { - const exit = yield* Effect.exit( - ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), - ) - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - }) - - describe("env option", () => { - fx.effect( - "passes environment variables with extendEnv", - Effect.gen(function* () { - const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', { - env: { TEST_VAR: "test_value" }, - extendEnv: true, - }) - const out = yield* decodeByteStream(handle.stdout) - expect(out).toBe("test_value") - }), - ) - - fx.effect( - "passes multiple environment variables", - Effect.gen(function* () { - const handle = yield* js( - "process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)", - { - env: { VAR1: "one", VAR2: "two", VAR3: "three" }, - extendEnv: true, - }, - ) - const out = yield* decodeByteStream(handle.stdout) - expect(out).toBe("one-two-three") - }), - ) - }) - - describe("stderr", () => { - fx.effect( - "captures stderr output", - Effect.gen(function* () { - const handle = yield* js('process.stderr.write("error message")') - const err = yield* decodeByteStream(handle.stderr) - expect(err).toBe("error message") - }), - ) - - fx.effect( - "captures both stdout and stderr", - Effect.gen(function* () { - const handle = yield* js( - [ - "let pending = 2", - "const done = () => {", - " pending -= 1", - " if (pending === 0) setTimeout(() => process.exit(0), 0)", - "}", - 'process.stdout.write("stdout\\n", done)', - 'process.stderr.write("stderr\\n", done)', - ].join("\n"), - ) - const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { - concurrency: 2, - }) - expect(stdout).toBe("stdout") - expect(stderr).toBe("stderr") - }), - ) - }) - - describe("combined output (all)", () => { - fx.effect( - "captures stdout via .all when no stderr", - Effect.gen(function* () { - const handle = yield* ChildProcess.make("echo", ["hello from stdout"]) - const all = yield* decodeByteStream(handle.all) - expect(all).toBe("hello from stdout") - }), - ) - - fx.effect( - "captures stderr via .all when no stdout", - Effect.gen(function* () { - const handle = yield* js('process.stderr.write("hello from stderr")') - const all = yield* decodeByteStream(handle.all) - expect(all).toBe("hello from stderr") - }), - ) - }) - - describe("stdin", () => { - fx.effect( - "allows providing standard input to a command", - Effect.gen(function* () { - const input = "a b c" - const stdin = Stream.make(Buffer.from(input, "utf-8")) - const handle = yield* js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', - { stdin }, - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("a b c") - }), - ) - }) - - describe("process control", () => { - fx.effect( - "kills a running process", - Effect.gen(function* () { - const exit = yield* Effect.exit( - Effect.gen(function* () { - const handle = yield* js("setTimeout(() => {}, 10_000)") - yield* handle.kill() - return yield* handle.exitCode - }), - ) - expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) - }), - ) - - fx.effect( - "kills a child when scope exits", - Effect.gen(function* () { - const pid = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* js("setInterval(() => {}, 10_000)") - return Number(handle.pid) - }), - ) - const done = yield* Effect.promise(() => gone(pid)) - expect(done).toBe(true) - }), - ) - - fx.effect( - "forceKillAfter escalates for stubborn processes", - Effect.gen(function* () { - if (process.platform === "win32") return - - const started = Date.now() - const exit = yield* Effect.exit( - Effect.gen(function* () { - const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)') - yield* handle.kill({ forceKillAfter: 100 }) - return yield* handle.exitCode - }), - ) - - expect(Date.now() - started).toBeLessThan(1_000) - expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) - }), - ) - - fx.effect( - "isRunning reflects process state", - Effect.gen(function* () { - const handle = yield* js('process.stdout.write("done")') - yield* handle.exitCode - const running = yield* handle.isRunning - expect(running).toBe(false) - }), - ) - }) - - describe("error handling", () => { - fx.effect( - "fails for invalid command", - Effect.gen(function* () { - const exit = yield* Effect.exit( - Effect.gen(function* () { - const handle = yield* ChildProcess.make("nonexistent-command-12345") - return yield* handle.exitCode - }), - ) - expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true) - }), - ) - }) - - describe("pipeline", () => { - fx.effect( - "pipes stdout of one command to stdin of another", - Effect.gen(function* () { - const handle = yield* js('process.stdout.write("hello world")').pipe( - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', - ), - ), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("HELLO WORLD") - }), - ) - - fx.effect( - "three-stage pipeline", - Effect.gen(function* () { - const handle = yield* js('process.stdout.write("hello world")').pipe( - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))', - ), - ), - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))', - ), - ), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("HELLO-WORLD") - }), - ) - - fx.effect( - "pipes stderr with { from: 'stderr' }", - Effect.gen(function* () { - const handle = yield* js('process.stderr.write("error")').pipe( - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', - ), - { from: "stderr" }, - ), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toBe("error") - }), - ) - - fx.effect( - "pipes combined output with { from: 'all' }", - Effect.gen(function* () { - const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe( - ChildProcess.pipeTo( - js( - 'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))', - ), - { from: "all" }, - ), - ) - const out = yield* decodeByteStream(handle.stdout) - yield* handle.exitCode - expect(out).toContain("stdout") - expect(out).toContain("stderr") - }), - ) - }) - - describe("Windows-specific", () => { - fx.effect( - "uses shell routing on Windows", - Effect.gen(function* () { - if (process.platform !== "win32") return - - const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => - svc.string( - ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], { - shell: true, - extendEnv: true, - env: { OPENCODE_TEST_SHELL: "ok" }, - }), - ), - ) - expect(out).toContain("OPENCODE_TEST_SHELL=ok") - }), - ) - - fx.effect( - "runs cmd scripts with spaces on Windows without shell", - Effect.gen(function* () { - if (process.platform !== "win32") return - - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir()), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ) - const dir = path.join(tmp.path, "with space") - const file = path.join(dir, "echo cmd.cmd") - - yield* Effect.promise(() => fs.mkdir(dir, { recursive: true })) - yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")) - - const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => - svc.exitCode( - ChildProcess.make(file, ["--stdio"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }), - ), - ) - expect(code).toBe(ChildProcessSpawner.ExitCode(0)) - }), - ) - }) -}) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 2f6f235aa..544359c60 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -3,7 +3,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Format } from "../../src/format" import * as Formatter from "../../src/format/formatter" diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index d138f56e3..8cb098826 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -3,7 +3,7 @@ import path from "path" import { Effect, Layer } from "effect" import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 13f21c93c..98ac600f4 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -3,7 +3,7 @@ import path from "path" import { Effect, Layer } from "effect" import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index b58c716d8..80601cd9a 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test" import os from "os" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Bus } from "../../src/bus" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index c61df3548..6579b414f 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -10,7 +10,7 @@ import { Effect, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" void Log.init({ print: false }) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index 5fb2beb28..b0cb626b1 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -3,7 +3,7 @@ import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" import { Effect, Layer } from "effect" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Worktree } from "../../src/worktree" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index c0fe63551..b20914c96 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" import { Cause, Effect, Exit, Layer } from "effect" -import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { Worktree } from "../../src/worktree" import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 4fe9c1551..79bdfe41f 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -25,7 +25,7 @@ import * as SessionProcessorModule from "../../src/session/processor" import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" void Log.init({ print: false }) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 74ce91307..d665022fd 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -19,7 +19,7 @@ import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 451f1d004..288ca8f99 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -38,7 +38,7 @@ import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool" import { Truncate } from "../../src/tool" import { Log } from "../../src/util" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index f28fb94c0..213e59632 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -9,7 +9,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import { Log } from "../../src/util" import { MessageID, PartID, SessionID } from "../../src/session/schema" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c7e352262..8c8c4da3b 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -52,7 +52,7 @@ import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool" import { Truncate } from "../../src/tool" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index e217300d0..41763fe97 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -6,7 +6,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" import { Account } from "../../src/account/account" import { AccountRepo } from "../../src/account/repo" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Bus } from "../../src/bus" import { Config } from "../../src/config" import { Provider } from "../../src/provider" diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 21c6c7e65..13f25be5b 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index 6be653ecb..f1b245f9b 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Exit, Layer } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Git } from "../../src/git" import { Global } from "@opencode-ai/core/global" import { Storage } from "../../src/storage" diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index fd35c9aeb..23f18f989 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -11,7 +11,7 @@ import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" import { Truncate } from "../../src/tool" import { SessionID, MessageID } from "../../src/session/schema" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Plugin } from "../../src/plugin" diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index c37e7b35f..8d496509a 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -3,7 +3,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "../../src/tool" diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index a279574e1..3e147dddc 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Truncate } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index b9d48e69a..07de4a0da 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 17718b2b3..53c413186 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -4,7 +4,7 @@ import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Truncate } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7c3bf51fe..27e6b71c5 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { LSP } from "../../src/lsp" import { Permission } from "../../src/permission" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index dbb89e09a..54c1d7706 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -3,7 +3,7 @@ import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { ToolRegistry } from "../../src/tool" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index b12940e4d..c67121e3c 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,4 +1,4 @@ -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Effect, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" import path from "path" diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b94dd5208..1eaa0cfc8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "../../src/config" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 0714d2d02..706ddb372 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -12,7 +12,7 @@ import { Truncate } from "../../src/tool" import { Tool } from "../../src/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { CrossSpawnSpawner } from "@opencode-ai/core/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -- cgit v1.2.3