Skip to content

Commit ca723f1

Browse files
authored
effect(core): add stdin option to AppProcess.run; migrate snapshot+clipboard (anomalyco#27224)
1 parent 650f67a commit ca723f1

4 files changed

Lines changed: 150 additions & 118 deletions

File tree

‎packages/core/src/process.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface RunOptions {
1616
readonly maxErrorBytes?: number
1717
readonly signal?: AbortSignal
1818
readonly timeout?: Duration.Input
19+
readonly stdin?: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>
1920
}
2021

2122
export interface RunStreamOptions {
@@ -96,6 +97,15 @@ const waitForAbort = (signal: AbortSignal) =>
9697
return Effect.sync(() => signal.removeEventListener("abort", onabort))
9798
})
9899

100+
const normalizeStdin = (
101+
input: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
102+
): Stream.Stream<Uint8Array, PlatformError> =>
103+
typeof input === "string"
104+
? Stream.make(new TextEncoder().encode(input))
105+
: input instanceof Uint8Array
106+
? Stream.make(input)
107+
: input
108+
99109
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
100110
Stream.runFold(
101111
stream,
@@ -119,7 +129,7 @@ export const layer = Layer.effect(
119129
Effect.gen(function* () {
120130
const spawner = yield* ChildProcessSpawner
121131

122-
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
132+
const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
123133
const description = describeCommand(command)
124134
const collect = Effect.scoped(
125135
Effect.gen(function* () {
@@ -154,7 +164,22 @@ export const layer = Layer.effect(
154164
),
155165
)
156166
: timed
157-
return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
167+
return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
168+
}
169+
170+
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
171+
if (options?.stdin === undefined) return yield* runCommand(command, options)
172+
if (command._tag !== "StandardCommand") {
173+
return yield* new AppProcessError({
174+
command: describeCommand(command),
175+
cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
176+
})
177+
}
178+
const next = ChildProcess.make(command.command, command.args, {
179+
...command.options,
180+
stdin: normalizeStdin(options.stdin),
181+
})
182+
return yield* runCommand(next, options)
158183
})
159184

160185
const runStream = (

‎packages/core/test/process/process.test.ts‎

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect } from "bun:test"
2+
import { realpathSync } from "node:fs"
3+
import { tmpdir } from "node:os"
24
import { Effect, Exit, Stream } from "effect"
35
import { ChildProcess } from "effect/unstable/process"
46
import { AppProcess } from "@opencode-ai/core/process"
@@ -123,6 +125,82 @@ describe("AppProcess", () => {
123125
)
124126
})
125127

128+
describe("run with stdin option", () => {
129+
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
130+
131+
it.effect(
132+
"feeds a string to stdin and returns it on stdout",
133+
Effect.gen(function* () {
134+
const svc = yield* AppProcess.Service
135+
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "hello" })
136+
expect(result.exitCode).toBe(0)
137+
expect(result.stdout.toString("utf8")).toBe("hello")
138+
}),
139+
)
140+
141+
it.effect(
142+
"feeds a Uint8Array to stdin",
143+
Effect.gen(function* () {
144+
const svc = yield* AppProcess.Service
145+
const bytes = new TextEncoder().encode("bytes")
146+
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: bytes })
147+
expect(result.exitCode).toBe(0)
148+
expect(result.stdout.toString("utf8")).toBe("bytes")
149+
}),
150+
)
151+
152+
it.effect(
153+
"feeds a Stream of Uint8Array chunks to stdin",
154+
Effect.gen(function* () {
155+
const svc = yield* AppProcess.Service
156+
const enc = new TextEncoder()
157+
const stream = Stream.fromIterable([enc.encode("one"), enc.encode("-two"), enc.encode("-three")])
158+
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: stream })
159+
expect(result.exitCode).toBe(0)
160+
expect(result.stdout.toString("utf8")).toBe("one-two-three")
161+
}),
162+
)
163+
164+
it.effect(
165+
"completes correctly with empty input",
166+
Effect.gen(function* () {
167+
const svc = yield* AppProcess.Service
168+
const result = yield* svc.run(cmd("-e", echoStdin), { stdin: "" })
169+
expect(result.exitCode).toBe(0)
170+
expect(result.stdout.toString("utf8")).toBe("")
171+
}),
172+
)
173+
174+
it.effect(
175+
"carries existing Command options like env",
176+
Effect.gen(function* () {
177+
const svc = yield* AppProcess.Service
178+
const script =
179+
"process.stdout.write(process.env.FEED + ':'); process.stdin.on('data', c => process.stdout.write(c))"
180+
const command = ChildProcess.make(NODE, ["-e", script], { env: { FEED: "envset" }, extendEnv: true })
181+
const result = yield* svc.run(command, { stdin: "payload" })
182+
expect(result.exitCode).toBe(0)
183+
expect(result.stdout.toString("utf8")).toBe("envset:payload")
184+
}),
185+
)
186+
187+
it.effect(
188+
"carries existing Command options like cwd",
189+
Effect.gen(function* () {
190+
const svc = yield* AppProcess.Service
191+
const dir = realpathSync(tmpdir())
192+
const script =
193+
"process.stdout.write(process.cwd() + '|'); process.stdin.on('data', c => process.stdout.write(c))"
194+
const command = ChildProcess.make(NODE, ["-e", script], { cwd: dir })
195+
const result = yield* svc.run(command, { stdin: "ok" })
196+
expect(result.exitCode).toBe(0)
197+
const [cwd, stdin] = result.stdout.toString("utf8").split("|")
198+
expect(realpathSync(cwd)).toBe(dir)
199+
expect(stdin).toBe("ok")
200+
}),
201+
)
202+
})
203+
126204
describe("runStream", () => {
127205
it.live(
128206
"emits lines incrementally and ends cleanly on exit 0",
@@ -136,11 +214,17 @@ describe("AppProcess", () => {
136214
)
137215

138216
it.live(
139-
"fails with AppProcessError when exit not in okExitCodes",
217+
"okExitCodes determines whether a non-zero exit fails the stream",
140218
Effect.gen(function* () {
141219
const svc = yield* AppProcess.Service
220+
const allowed = yield* svc
221+
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
222+
.pipe(Stream.runCollect)
223+
expect(Array.from(allowed)).toEqual(["only"])
142224
const exit = yield* Effect.exit(
143-
svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect),
225+
svc
226+
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
227+
.pipe(Stream.runCollect),
144228
)
145229
expect(Exit.isFailure(exit)).toBe(true)
146230
if (Exit.isFailure(exit)) {
@@ -152,17 +236,6 @@ describe("AppProcess", () => {
152236
}),
153237
)
154238

155-
it.live(
156-
"okExitCodes allowlist treats non-zero as success",
157-
Effect.gen(function* () {
158-
const svc = yield* AppProcess.Service
159-
const result = yield* svc
160-
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
161-
.pipe(Stream.runCollect)
162-
expect(Array.from(result)).toEqual(["only"])
163-
}),
164-
)
165-
166239
it.live(
167240
"without okExitCodes, never fails on exit code",
168241
Effect.gen(function* () {
@@ -177,12 +250,10 @@ describe("AppProcess", () => {
177250
Effect.gen(function* () {
178251
const svc = yield* AppProcess.Service
179252
const controller = new AbortController()
180-
setTimeout(() => controller.abort(), 50)
253+
controller.abort()
181254
const exit = yield* Effect.exit(
182255
svc
183-
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
184-
signal: controller.signal,
185-
})
256+
.runStream(cmd("-e", "setInterval(() => {}, 60_000)"), { signal: controller.signal })
186257
.pipe(Stream.runCollect),
187258
)
188259
expect(Exit.isFailure(exit)).toBe(true)

‎packages/opencode/src/cli/cmd/tui/util/clipboard.ts‎

Lines changed: 18 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,21 @@ import { lazy } from "../../../../util/lazy.js"
33
import { tmpdir } from "os"
44
import path from "path"
55
import fs from "fs/promises"
6+
import { Effect } from "effect"
7+
import { ChildProcess } from "effect/unstable/process"
8+
import { AppProcess } from "@opencode-ai/core/process"
69
import * as Filesystem from "../../../../util/filesystem"
710
import * as Process from "../../../../util/process"
811

12+
const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
13+
Effect.runPromise(
14+
AppProcess.Service.use((svc) => svc.run(ChildProcess.make(cmd[0]!, cmd.slice(1)), { stdin: text })).pipe(
15+
Effect.provide(AppProcess.defaultLayer),
16+
Effect.catch(() => Effect.void),
17+
Effect.asVoid,
18+
),
19+
).catch(() => undefined)
20+
921
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
1022
const getWhich = lazy(async () => {
1123
const { which } = await import("../../../../util/which")
@@ -125,68 +137,32 @@ const getCopyMethod = lazy(async () => {
125137
if (os === "linux") {
126138
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
127139
console.log("clipboard: using wl-copy")
128-
return async (text: string) => {
129-
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
130-
if (!proc.stdin) return
131-
proc.stdin.write(text)
132-
proc.stdin.end()
133-
await proc.exited.catch(() => {})
134-
}
140+
return (text: string) => writeWithStdin(["wl-copy"], text)
135141
}
136142
if (which("xclip")) {
137143
console.log("clipboard: using xclip")
138-
return async (text: string) => {
139-
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
140-
stdin: "pipe",
141-
stdout: "ignore",
142-
stderr: "ignore",
143-
})
144-
if (!proc.stdin) return
145-
proc.stdin.write(text)
146-
proc.stdin.end()
147-
await proc.exited.catch(() => {})
148-
}
144+
return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
149145
}
150146
if (which("xsel")) {
151147
console.log("clipboard: using xsel")
152-
return async (text: string) => {
153-
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
154-
stdin: "pipe",
155-
stdout: "ignore",
156-
stderr: "ignore",
157-
})
158-
if (!proc.stdin) return
159-
proc.stdin.write(text)
160-
proc.stdin.end()
161-
await proc.exited.catch(() => {})
162-
}
148+
return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
163149
}
164150
}
165151

166152
if (os === "win32") {
167153
console.log("clipboard: using powershell")
168-
return async (text: string) => {
154+
return (text: string) =>
169155
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
170-
const proc = Process.spawn(
156+
writeWithStdin(
171157
[
172158
"powershell.exe",
173159
"-NonInteractive",
174160
"-NoProfile",
175161
"-Command",
176162
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
177163
],
178-
{
179-
stdin: "pipe",
180-
stdout: "ignore",
181-
stderr: "ignore",
182-
},
164+
text,
183165
)
184-
185-
if (!proc.stdin) return
186-
proc.stdin.write(text)
187-
proc.stdin.end()
188-
await proc.exited.catch(() => {})
189-
}
190166
}
191167

192168
console.log("clipboard: no native support")

0 commit comments

Comments
 (0)