mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
effect(core): add stdin option to AppProcess.run; migrate snapshot+clipboard (#27224)
This commit is contained in:
@@ -16,6 +16,7 @@ export interface RunOptions {
|
||||
readonly maxErrorBytes?: number
|
||||
readonly signal?: AbortSignal
|
||||
readonly timeout?: Duration.Input
|
||||
readonly stdin?: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>
|
||||
}
|
||||
|
||||
export interface RunStreamOptions {
|
||||
@@ -96,6 +97,15 @@ const waitForAbort = (signal: AbortSignal) =>
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
||||
})
|
||||
|
||||
const normalizeStdin = (
|
||||
input: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
|
||||
): Stream.Stream<Uint8Array, PlatformError> =>
|
||||
typeof input === "string"
|
||||
? Stream.make(new TextEncoder().encode(input))
|
||||
: input instanceof Uint8Array
|
||||
? Stream.make(input)
|
||||
: input
|
||||
|
||||
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
@@ -119,7 +129,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
|
||||
const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
|
||||
const description = describeCommand(command)
|
||||
const collect = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -154,7 +164,22 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
: timed
|
||||
return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
|
||||
return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
|
||||
}
|
||||
|
||||
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
|
||||
if (options?.stdin === undefined) return yield* runCommand(command, options)
|
||||
if (command._tag !== "StandardCommand") {
|
||||
return yield* new AppProcessError({
|
||||
command: describeCommand(command),
|
||||
cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
|
||||
})
|
||||
}
|
||||
const next = ChildProcess.make(command.command, command.args, {
|
||||
...command.options,
|
||||
stdin: normalizeStdin(options.stdin),
|
||||
})
|
||||
return yield* runCommand(next, options)
|
||||
})
|
||||
|
||||
const runStream = (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { realpathSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { Effect, Exit, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
@@ -123,6 +125,82 @@ describe("AppProcess", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("run with stdin option", () => {
|
||||
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
|
||||
|
||||
it.effect(
|
||||
"feeds a string to stdin and returns it on stdout",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"feeds a Uint8Array to stdin",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const bytes = new TextEncoder().encode("bytes")
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("bytes")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"feeds a Stream of Uint8Array chunks to stdin",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const enc = new TextEncoder()
|
||||
const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("one-two-three")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"completes correctly with empty input",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"carries existing Command options like env",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const script =
|
||||
"process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
|
||||
const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
|
||||
const result = yield* svc.run(command, { stdin: "payload" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("envset:payload")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"carries existing Command options like cwd",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const dir = realpathSync(tmpdir())
|
||||
const script =
|
||||
"process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
|
||||
const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
|
||||
const result = yield* svc.run(command, { stdin: "ok" })
|
||||
expect(result.exitCode).toBe(0)
|
||||
const [cwd, stdin] = result.stdout.toString("utf8").split("|")
|
||||
expect(realpathSync(cwd)).toBe(dir)
|
||||
expect(stdin).toBe("ok")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("runStream", () => {
|
||||
it.live(
|
||||
"emits lines incrementally and ends cleanly on exit 0",
|
||||
@@ -136,11 +214,17 @@ describe("AppProcess", () => {
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fails with AppProcessError when exit not in okExitCodes",
|
||||
"okExitCodes determines whether a non-zero exit fails the stream",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const allowed = yield* svc
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(allowed)).toEqual(["only"])
|
||||
const exit = yield* Effect.exit(
|
||||
svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect),
|
||||
svc
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
@@ -152,17 +236,6 @@ describe("AppProcess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"okExitCodes allowlist treats non-zero as success",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["only"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"without okExitCodes, never fails on exit code",
|
||||
Effect.gen(function* () {
|
||||
@@ -177,12 +250,10 @@ describe("AppProcess", () => {
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(), 50)
|
||||
controller.abort()
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
||||
Reference in New Issue
Block a user