Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed580ec051 | |||
| a0f3716e18 | |||
| bf596d5b22 |
@@ -0,0 +1,239 @@
|
||||
import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||
|
||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||
command: Schema.String,
|
||||
exitCode: Schema.optional(Schema.Number),
|
||||
stderr: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface RunOptions {
|
||||
readonly maxOutputBytes?: number
|
||||
readonly maxErrorBytes?: number
|
||||
readonly signal?: AbortSignal
|
||||
readonly timeout?: Duration.Input
|
||||
}
|
||||
|
||||
export interface RunStreamOptions {
|
||||
readonly signal?: AbortSignal
|
||||
readonly includeStderr?: boolean
|
||||
readonly okExitCodes?: ReadonlyArray<number>
|
||||
readonly maxErrorBytes?: number
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
readonly command: string
|
||||
readonly exitCode: number
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export type Interface = ChildProcessSpawner["Service"] & {
|
||||
readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
|
||||
readonly runStream: (
|
||||
command: ChildProcess.Command,
|
||||
options?: RunStreamOptions,
|
||||
) => Stream.Stream<string, AppProcessError>
|
||||
readonly runWithStdin: (
|
||||
command: ChildProcess.Command,
|
||||
stdin: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
|
||||
options?: RunOptions,
|
||||
) => Effect.Effect<RunResult, AppProcessError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AppProcess") {}
|
||||
|
||||
export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
|
||||
result.exitCode === 0
|
||||
? Effect.succeed(result)
|
||||
: Effect.fail(
|
||||
new AppProcessError({
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}),
|
||||
)
|
||||
|
||||
export const requireExitIn =
|
||||
(codes: ReadonlyArray<number>) =>
|
||||
(result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
|
||||
codes.includes(result.exitCode)
|
||||
? Effect.succeed(result)
|
||||
: Effect.fail(
|
||||
new AppProcessError({
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}),
|
||||
)
|
||||
|
||||
const describeCommand = (command: ChildProcess.Command): string => {
|
||||
if (command._tag === "StandardCommand") {
|
||||
return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
|
||||
}
|
||||
return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
|
||||
}
|
||||
|
||||
const wrapError = (description: string, cause: unknown): AppProcessError =>
|
||||
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
|
||||
|
||||
const abortError = (signal: AbortSignal): Error => {
|
||||
const reason = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
const err = new Error("Aborted")
|
||||
err.name = "AbortError"
|
||||
return err
|
||||
}
|
||||
|
||||
const waitForAbort = (signal: AbortSignal) =>
|
||||
Effect.callback<never, Error>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.fail(abortError(signal)))
|
||||
return
|
||||
}
|
||||
const onabort = () => resume(Effect.fail(abortError(signal)))
|
||||
signal.addEventListener("abort", onabort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
||||
})
|
||||
|
||||
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
(acc, chunk) => {
|
||||
if (maxOutputBytes === undefined) {
|
||||
acc.chunks.push(chunk)
|
||||
acc.bytes += chunk.length
|
||||
return acc
|
||||
}
|
||||
const remaining = maxOutputBytes - acc.bytes
|
||||
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
|
||||
acc.bytes += chunk.length
|
||||
acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
|
||||
return acc
|
||||
},
|
||||
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
|
||||
const description = describeCommand(command)
|
||||
const collect = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, options?.maxOutputBytes),
|
||||
collectStream(handle.stderr, options?.maxErrorBytes),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return {
|
||||
command: description,
|
||||
exitCode,
|
||||
stdout: stdout.buffer,
|
||||
stderr: stderr.buffer,
|
||||
truncated: stdout.truncated,
|
||||
} satisfies RunResult
|
||||
}),
|
||||
)
|
||||
const timed = options?.timeout
|
||||
? Effect.timeoutOrElse(collect, {
|
||||
duration: options.timeout,
|
||||
orElse: () =>
|
||||
Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
|
||||
})
|
||||
: collect
|
||||
const aborted = options?.signal
|
||||
? timed.pipe(
|
||||
Effect.raceFirst(
|
||||
waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
|
||||
),
|
||||
)
|
||||
: timed
|
||||
return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
|
||||
}
|
||||
|
||||
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
|
||||
return yield* runCommand(command, options)
|
||||
})
|
||||
|
||||
const runWithStdin = Effect.fn("AppProcess.runWithStdin")(function* (
|
||||
command: ChildProcess.Command,
|
||||
stdin: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
|
||||
options?: RunOptions,
|
||||
) {
|
||||
if (command._tag !== "StandardCommand") {
|
||||
return yield* Effect.fail(
|
||||
new AppProcessError({
|
||||
command: describeCommand(command),
|
||||
cause: new Error("runWithStdin only supports StandardCommand; received PipedCommand"),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const stream =
|
||||
typeof stdin === "string"
|
||||
? Stream.make(new TextEncoder().encode(stdin))
|
||||
: stdin instanceof Uint8Array
|
||||
? Stream.make(stdin)
|
||||
: stdin
|
||||
const next = ChildProcess.make(command.command, command.args, { ...command.options, stdin: stream })
|
||||
return yield* runCommand(next, options)
|
||||
})
|
||||
|
||||
const runStream = (command: ChildProcess.Command, options?: RunStreamOptions): Stream.Stream<string, AppProcessError> => {
|
||||
const description = describeCommand(command)
|
||||
const okExitCodes = options?.okExitCodes
|
||||
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const stderrFiber = yield* Effect.forkScoped(
|
||||
collectStream(handle.stderr, options?.maxErrorBytes).pipe(
|
||||
Effect.map((x) => x.buffer.toString("utf8")),
|
||||
),
|
||||
)
|
||||
const source = options?.includeStderr === true ? handle.all : handle.stdout
|
||||
const lines = source.pipe(
|
||||
Stream.decodeText,
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
)
|
||||
const tail = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const code = yield* handle.exitCode
|
||||
if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
|
||||
}
|
||||
return Stream.empty
|
||||
}),
|
||||
)
|
||||
return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
|
||||
}),
|
||||
)
|
||||
const mapped = built.pipe(
|
||||
Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
|
||||
)
|
||||
if (!options?.signal) return mapped
|
||||
const signal = options.signal
|
||||
return mapped.pipe(
|
||||
Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
|
||||
)
|
||||
}
|
||||
|
||||
return Service.of({ ...spawner, run, runStream, runWithStdin })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
@@ -0,0 +1,309 @@
|
||||
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"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppProcess.defaultLayer)
|
||||
|
||||
const NODE = process.execPath
|
||||
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
|
||||
|
||||
describe("AppProcess", () => {
|
||||
describe("run", () => {
|
||||
it.effect(
|
||||
"captures stdout and exit code zero",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("hi\n")
|
||||
expect(result.truncated).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"non-zero exit returns RunResult; caller can require success",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.exit(1)"))
|
||||
expect(result.exitCode).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireSuccess fails on non-zero exit",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
|
||||
} else {
|
||||
throw new Error("expected fail reason")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireSuccess succeeds on exit 0",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
expect(result.exitCode).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireExitIn allowlists multiple exit codes",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
|
||||
const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okZero.exitCode).toBe(0)
|
||||
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okOne.exitCode).toBe(1)
|
||||
const exit = yield* Effect.exit(
|
||||
svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"truncates output when maxOutputBytes is set",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.stdout.length).toBe(5)
|
||||
expect(result.stdout.toString("utf8")).toBe("01234")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"result includes command description",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
|
||||
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("inherited platform methods", () => {
|
||||
it.effect(
|
||||
"string returns stdout as string",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(out).toBe("hi\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"lines returns the platform's array of lines",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
|
||||
expect(Array.from(out)).toEqual(["a", "b"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("runWithStdin", () => {
|
||||
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.runWithStdin(cmd("-e", echoStdin), "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.runWithStdin(cmd("-e", echoStdin), 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.runWithStdin(cmd("-e", echoStdin), 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.runWithStdin(cmd("-e", echoStdin), "")
|
||||
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.runWithStdin(command, "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.runWithStdin(command, "ok")
|
||||
expect(result.exitCode).toBe(0)
|
||||
const [cwd, stdin] = result.stdout.toString("utf8").split("|")
|
||||
expect(realpathSync(cwd)).toBe(dir)
|
||||
expect(stdin).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"interacts with requireSuccess just like run",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const ok = yield* svc.runWithStdin(cmd("-e", echoStdin), "x").pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
expect(ok.exitCode).toBe(0)
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runWithStdin(cmd("-e", "process.stdin.on('data', () => {}); process.stdin.on('end', () => process.exit(2))"), "x")
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("runStream", () => {
|
||||
it.live(
|
||||
"emits lines incrementally and ends cleanly on exit 0",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc
|
||||
.runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["one", "two", "three"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fails with AppProcessError when exit not in okExitCodes",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
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* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(7)"))
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["only"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"AbortSignal interrupts the stream",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(), 250)
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 50); setTimeout(() => {}, 60_000)"), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("spawn (inherited)", () => {
|
||||
it.live(
|
||||
"returns the platform ChildProcessHandle for advanced use",
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
|
||||
expect(yield* handle.isRunning).toBe(true)
|
||||
yield* handle.kill()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { SessionTable, MessageTable, PartTable } from "../../session/session.sql
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
|
||||
@@ -95,7 +95,6 @@ export const ImportCommand = effectCmd({
|
||||
|
||||
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
|
||||
const share = yield* ShareNext.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
let exportData: ExportData | undefined
|
||||
|
||||
@@ -150,9 +149,9 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI
|
||||
|
||||
exportData = transformed
|
||||
} else {
|
||||
exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as
|
||||
| NonNullable<typeof exportData>
|
||||
| undefined
|
||||
exportData = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
|
||||
)
|
||||
if (!exportData) {
|
||||
process.stdout.write(`File not found: ${file}`)
|
||||
process.stdout.write(EOL)
|
||||
|
||||
@@ -3,9 +3,21 @@ import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import * as Filesystem from "../../../../util/filesystem"
|
||||
import * as Process from "../../../../util/process"
|
||||
|
||||
const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
|
||||
Effect.runPromise(
|
||||
AppProcess.Service.use((svc) => svc.runWithStdin(ChildProcess.make(cmd[0]!, cmd.slice(1)), text)).pipe(
|
||||
Effect.provide(AppProcess.defaultLayer),
|
||||
Effect.catch(() => Effect.void),
|
||||
Effect.asVoid,
|
||||
),
|
||||
).catch(() => undefined)
|
||||
|
||||
// Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
|
||||
const getWhich = lazy(async () => {
|
||||
const { which } = await import("../../../../util/which")
|
||||
@@ -125,49 +137,23 @@ const getCopyMethod = lazy(async () => {
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
return (text: string) => writeWithStdin(["wl-copy"], text)
|
||||
}
|
||||
if (which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
return (text: string) => writeWithStdin(["xclip", "-selection", "clipboard"], text)
|
||||
}
|
||||
if (which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
return (text: string) => writeWithStdin(["xsel", "--clipboard", "--input"], text)
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "win32") {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
return (text: string) =>
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Process.spawn(
|
||||
writeWithStdin(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
@@ -175,18 +161,8 @@ const getCopyMethod = lazy(async () => {
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
text,
|
||||
)
|
||||
|
||||
if (!proc.stdin) return
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
console.log("clipboard: no native support")
|
||||
|
||||
@@ -11,8 +11,8 @@ import { Auth } from "@/auth"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
@@ -175,7 +175,6 @@ export const layer = Layer.effect(
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const sync = yield* SyncEvent.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const connections = new Map<WorkspaceID, ConnectionStatus>()
|
||||
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
|
||||
|
||||
@@ -501,7 +500,7 @@ export const layer = Layer.effect(
|
||||
if (!target) return
|
||||
|
||||
if (target.type === "local") {
|
||||
setStatus(space.id, (yield* fs.existsSafe(target.directory)) ? "connected" : "error")
|
||||
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1040,7 +1039,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(SessionPrompt.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
@@ -58,12 +58,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sn
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
|
||||
AppFileSystem.Service | AppProcess.Service | Config.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
@@ -87,29 +87,48 @@ export const layer: Layer.Layer<
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const enc = new TextEncoder()
|
||||
const feed = (list: string[]) => Stream.make(enc.encode(list.join("\0") + "\0"))
|
||||
const feed = (list: string[]) => list.join("\0") + "\0"
|
||||
|
||||
const gitWithStdin = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts: { cwd?: string; env?: Record<string, string>; stdin: string }) {
|
||||
const result = yield* appProcess.runWithStdin(
|
||||
ChildProcess.make("git", cmd, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
opts.stdin,
|
||||
)
|
||||
return {
|
||||
code: ChildProcessSpawner.ExitCode(result.exitCode),
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
} satisfies GitResult
|
||||
},
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (
|
||||
cmd: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string>; stdin?: ChildProcess.CommandInput },
|
||||
) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
stdin: opts?.stdin,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const result = yield* appProcess.run(
|
||||
ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
return {
|
||||
code: ChildProcessSpawner.ExitCode(result.exitCode),
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
} satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
@@ -121,7 +140,7 @@ export const layer: Layer.Layer<
|
||||
|
||||
const ignore = Effect.fnUntraced(function* (files: string[]) {
|
||||
if (!files.length) return new Set<string>()
|
||||
const check = yield* git(
|
||||
const check = yield* gitWithStdin(
|
||||
[
|
||||
...quote,
|
||||
"--git-dir",
|
||||
@@ -144,7 +163,7 @@ export const layer: Layer.Layer<
|
||||
|
||||
const drop = Effect.fnUntraced(function* (files: string[]) {
|
||||
if (!files.length) return
|
||||
yield* git(
|
||||
yield* gitWithStdin(
|
||||
[
|
||||
...cfg,
|
||||
...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
|
||||
@@ -158,7 +177,7 @@ export const layer: Layer.Layer<
|
||||
|
||||
const stage = Effect.fnUntraced(function* (files: string[]) {
|
||||
if (!files.length) return
|
||||
const result = yield* git(
|
||||
const result = yield* gitWithStdin(
|
||||
[...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
@@ -565,24 +584,21 @@ export const layer: Layer.Layer<
|
||||
})
|
||||
if (!refs.length) return new Map<string, { before: string; after: string }>()
|
||||
|
||||
const proc = ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
|
||||
cwd: state.directory,
|
||||
extendEnv: true,
|
||||
stdin: Stream.make(new TextEncoder().encode(refs.map((item) => item.ref).join("\n") + "\n")),
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [out, err] = yield* Effect.all(
|
||||
[Stream.mkUint8Array(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
const batch = yield* appProcess.runWithStdin(
|
||||
ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
|
||||
cwd: state.directory,
|
||||
extendEnv: true,
|
||||
}),
|
||||
refs.map((item) => item.ref).join("\n") + "\n",
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
if (code !== 0) {
|
||||
if (batch.exitCode !== 0) {
|
||||
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
|
||||
stderr: err,
|
||||
stderr: batch.stderr.toString("utf8"),
|
||||
refs: refs.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
const out = batch.stdout
|
||||
|
||||
const fail = (msg: string, extra?: Record<string, string>) => {
|
||||
log.info(msg, { ...extra, refs: refs.length })
|
||||
@@ -767,7 +783,7 @@ export const layer: Layer.Layer<
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -160,7 +161,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
const filepath = path.join(test.directory, "test.json")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
|
||||
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
|
||||
expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain("application/json")
|
||||
|
||||
const result = yield* read("test.json")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -180,7 +181,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
for (const testCase of testCases) {
|
||||
const filepath = path.join(test.directory, `test.${testCase.ext}`)
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
|
||||
expect(yield* Effect.promise(() => Filesystem.mimeType(filepath))).toContain(testCase.mime)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -188,16 +189,15 @@ describe("file/index Filesystem patterns", () => {
|
||||
|
||||
describe("list() - Filesystem.exists() and readText()", () => {
|
||||
it.instance(
|
||||
"reads .gitignore via AppFileSystem.existsSafe() and readFileString()",
|
||||
"reads .gitignore via Filesystem.exists() and readText()",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const gitignorePath = path.join(test.directory, ".gitignore")
|
||||
yield* fsys.writeFileString(gitignorePath, "node_modules\ndist\n")
|
||||
yield* Effect.promise(() => fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8"))
|
||||
|
||||
expect(yield* fsys.existsSafe(gitignorePath)).toBe(true)
|
||||
expect(yield* fsys.readFileString(gitignorePath)).toContain("node_modules")
|
||||
expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(true)
|
||||
expect(yield* Effect.promise(() => Filesystem.readText(gitignorePath))).toContain("node_modules")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -206,13 +206,12 @@ describe("file/index Filesystem patterns", () => {
|
||||
"reads .ignore file similarly",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const ignorePath = path.join(test.directory, ".ignore")
|
||||
yield* fsys.writeFileString(ignorePath, "*.log\n.env\n")
|
||||
yield* Effect.promise(() => fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8"))
|
||||
|
||||
expect(yield* fsys.existsSafe(ignorePath)).toBe(true)
|
||||
expect(yield* fsys.readFileString(ignorePath)).toContain("*.log")
|
||||
expect(yield* Effect.promise(() => Filesystem.exists(ignorePath))).toBe(true)
|
||||
expect(yield* Effect.promise(() => Filesystem.readText(ignorePath))).toContain("*.log")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -221,10 +220,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
"handles missing .gitignore gracefully",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const gitignorePath = path.join(test.directory, ".gitignore")
|
||||
expect(yield* fsys.existsSafe(gitignorePath)).toBe(false)
|
||||
expect(yield* Effect.promise(() => Filesystem.exists(gitignorePath))).toBe(false)
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(Array.isArray(nodes)).toBe(true)
|
||||
@@ -233,17 +231,16 @@ describe("file/index Filesystem patterns", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("File.changed() - AppFileSystem.readFileString() for untracked files", () => {
|
||||
describe("File.changed() - Filesystem.readText() for untracked files", () => {
|
||||
it.instance(
|
||||
"reads untracked files via AppFileSystem.readFileString()",
|
||||
"reads untracked files via Filesystem.readText()",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const untrackedPath = path.join(test.directory, "untracked.txt")
|
||||
yield* fsys.writeFileString(untrackedPath, "new content\nwith multiple lines")
|
||||
yield* Effect.promise(() => fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8"))
|
||||
|
||||
const content = yield* fsys.readFileString(untrackedPath)
|
||||
const content = yield* Effect.promise(() => Filesystem.readText(untrackedPath))
|
||||
expect(content.split("\n").length).toBe(2)
|
||||
}),
|
||||
{ git: true },
|
||||
@@ -251,26 +248,28 @@ describe("file/index Filesystem patterns", () => {
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
it.instance("handles errors gracefully in AppFileSystem.readFileString()", () =>
|
||||
it.instance("handles errors gracefully in Filesystem.readText()", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fsys.writeFileString(path.join(test.directory, "readonly.txt"), "content")
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "readonly.txt"), "content", "utf-8"))
|
||||
|
||||
const nonExistentPath = path.join(test.directory, "does-not-exist.txt")
|
||||
expect(Exit.isFailure(yield* fsys.readFileString(nonExistentPath).pipe(Effect.exit))).toBe(true)
|
||||
expect(
|
||||
Exit.isFailure(yield* Effect.promise(() => Filesystem.readText(nonExistentPath)).pipe(Effect.exit)),
|
||||
).toBe(true)
|
||||
|
||||
const result = yield* read("does-not-exist.txt")
|
||||
expect(result.content).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles errors in AppFileSystem.readFile()", () =>
|
||||
it.instance("handles errors in Filesystem.readArrayBuffer()", () =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const test = yield* TestInstance
|
||||
const nonExistentPath = path.join(test.directory, "does-not-exist.bin")
|
||||
const buffer = yield* fsys.readFile(nonExistentPath).pipe(Effect.orElseSucceed(() => new Uint8Array(0)))
|
||||
const buffer = yield* Effect.promise(() =>
|
||||
Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0)),
|
||||
)
|
||||
expect(buffer.byteLength).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
@@ -30,7 +30,7 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
|
||||
function withTmp<T, A, E, R>(
|
||||
init: (dir: string) => Promise<T>,
|
||||
@@ -846,7 +846,7 @@ describe("plugin.loader.shared", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* load(tmp.path)
|
||||
expect(
|
||||
(yield* (yield* AppFileSystem.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
|
||||
yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)),
|
||||
).toEqual({
|
||||
source: "tuple",
|
||||
enabled: true,
|
||||
@@ -980,8 +980,7 @@ export default {
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.extra.mod, "package.json")
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const json = (yield* fsys.readJson(file)) as Record<string, unknown>
|
||||
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(file))
|
||||
const list = readPackageThemes("acme-plugin", {
|
||||
dir: tmp.extra.mod,
|
||||
pkg: file,
|
||||
@@ -989,8 +988,8 @@ export default {
|
||||
})
|
||||
|
||||
expect(list).toEqual([
|
||||
AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||
AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||
])
|
||||
}),
|
||||
),
|
||||
@@ -1054,7 +1053,7 @@ export default {
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
target: tmp.extra.mod,
|
||||
themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
expect(missing).toHaveLength(0)
|
||||
@@ -1117,7 +1116,7 @@ export default {
|
||||
expect(loaded).toEqual([
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
themes: [AppFileSystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
@@ -1138,8 +1137,7 @@ export default {
|
||||
},
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
|
||||
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(tmp.extra.file))
|
||||
expect(() =>
|
||||
readPackageThemes("acme", {
|
||||
dir: tmp.extra.mod,
|
||||
|
||||
@@ -1195,7 +1195,7 @@ describe("tool.shell truncation", () => {
|
||||
const filepath = (result.metadata as { outputPath?: string }).outputPath
|
||||
expect(filepath).toBeTruthy()
|
||||
|
||||
const saved = yield* (yield* AppFileSystem.Service).readFileString(filepath!)
|
||||
const saved = yield* Effect.promise(() => Filesystem.readText(filepath!))
|
||||
const lines = saved.trim().split(/\r?\n/)
|
||||
expect(lines.length).toBe(lineCount)
|
||||
expect(lines[0]).toBe("1")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Config } from "@/config/config"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Process } from "@/util/process"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import path from "path"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { writeFileStringScoped } from "../lib/filesystem"
|
||||
@@ -14,15 +14,10 @@ import { TestConfig } from "../fixture/config"
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
const configuredLayer = (cfg: Config.Info) =>
|
||||
Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
NodeFileSystem.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
TestConfig.layer({ get: () => Effect.succeed(cfg) }),
|
||||
)
|
||||
Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, TestConfig.layer({ get: () => Effect.succeed(cfg) }))
|
||||
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))
|
||||
|
||||
describe("Truncate", () => {
|
||||
@@ -30,8 +25,7 @@ describe("Truncate", () => {
|
||||
it.live("truncates large json file by bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Truncate.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||
const result = yield* svc.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
@@ -164,8 +158,7 @@ describe("Truncate", () => {
|
||||
it.live("large single-line file truncates with byte message", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Truncate.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||
const result = yield* svc.output(content)
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
@@ -187,8 +180,7 @@ describe("Truncate", () => {
|
||||
expect(result.outputPath).toBeDefined()
|
||||
expect(result.outputPath).toContain("tool_")
|
||||
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const written = yield* fsys.readFileString(result.outputPath!)
|
||||
const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
|
||||
expect(written).toBe(lines)
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user