process.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
  2. import type { PlatformError } from "effect/PlatformError"
  3. import { ChildProcess } from "effect/unstable/process"
  4. import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
  5. import { CrossSpawnSpawner } from "./cross-spawn-spawner"
  6. import { LayerNode } from "./effect/layer-node"
  7. export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
  8. command: Schema.String,
  9. exitCode: Schema.optional(Schema.Number),
  10. stderr: Schema.optional(Schema.String),
  11. cause: Schema.optional(Schema.Defect()),
  12. }) {
  13. override get message() {
  14. const detail =
  15. this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause))
  16. const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})`
  17. return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}`
  18. }
  19. }
  20. export interface RunOptions {
  21. readonly maxOutputBytes?: number
  22. readonly maxErrorBytes?: number
  23. readonly signal?: AbortSignal
  24. readonly timeout?: Duration.Input
  25. readonly stdin?: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>
  26. }
  27. export interface RunStreamOptions {
  28. readonly signal?: AbortSignal
  29. readonly includeStderr?: boolean
  30. readonly okExitCodes?: ReadonlyArray<number>
  31. readonly maxErrorBytes?: number
  32. }
  33. export interface RunResult {
  34. readonly command: string
  35. readonly exitCode: number
  36. readonly stdout: Buffer
  37. readonly stderr: Buffer
  38. readonly stdoutTruncated: boolean
  39. readonly stderrTruncated: boolean
  40. }
  41. export type Interface = ChildProcessSpawner["Service"] & {
  42. readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
  43. readonly runStream: (
  44. command: ChildProcess.Command,
  45. options?: RunStreamOptions,
  46. ) => Stream.Stream<string, AppProcessError>
  47. }
  48. export class Service extends Context.Service<Service, Interface>()("@opencode/AppProcess") {}
  49. export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
  50. result.exitCode === 0
  51. ? Effect.succeed(result)
  52. : Effect.fail(
  53. new AppProcessError({
  54. command: result.command,
  55. exitCode: result.exitCode,
  56. stderr: result.stderr.toString("utf8"),
  57. }),
  58. )
  59. export const requireExitIn =
  60. (codes: ReadonlyArray<number>) =>
  61. (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
  62. codes.includes(result.exitCode)
  63. ? Effect.succeed(result)
  64. : Effect.fail(
  65. new AppProcessError({
  66. command: result.command,
  67. exitCode: result.exitCode,
  68. stderr: result.stderr.toString("utf8"),
  69. }),
  70. )
  71. const describeCommand = (command: ChildProcess.Command): string => {
  72. if (command._tag === "StandardCommand") {
  73. return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
  74. }
  75. return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
  76. }
  77. const wrapError = (description: string, cause: unknown): AppProcessError =>
  78. cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
  79. export const abortError = (signal: AbortSignal): Error => {
  80. const reason = signal.reason
  81. if (reason instanceof Error) return reason
  82. const err = new Error("Aborted")
  83. err.name = "AbortError"
  84. return err
  85. }
  86. export const waitForAbort = (signal: AbortSignal) =>
  87. Effect.callback<never, Error>((resume) => {
  88. if (signal.aborted) {
  89. resume(Effect.fail(abortError(signal)))
  90. return
  91. }
  92. const onabort = () => resume(Effect.fail(abortError(signal)))
  93. signal.addEventListener("abort", onabort, { once: true })
  94. return Effect.sync(() => signal.removeEventListener("abort", onabort))
  95. })
  96. const normalizeStdin = (
  97. input: string | Uint8Array | Stream.Stream<Uint8Array, PlatformError>,
  98. ): Stream.Stream<Uint8Array, PlatformError> =>
  99. typeof input === "string"
  100. ? Stream.make(new TextEncoder().encode(input))
  101. : input instanceof Uint8Array
  102. ? Stream.make(input)
  103. : input
  104. export const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
  105. Stream.runFold(
  106. stream,
  107. () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
  108. (acc, chunk) => {
  109. if (maxOutputBytes === undefined) {
  110. acc.chunks.push(chunk)
  111. acc.bytes += chunk.length
  112. return acc
  113. }
  114. const remaining = maxOutputBytes - acc.bytes
  115. if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
  116. acc.bytes += chunk.length
  117. acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
  118. return acc
  119. },
  120. ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
  121. export const layer = Layer.effect(
  122. Service,
  123. Effect.gen(function* () {
  124. const spawner = yield* ChildProcessSpawner
  125. const runCommand = (command: ChildProcess.Command, options?: RunOptions) => {
  126. const description = describeCommand(command)
  127. const collect = Effect.scoped(
  128. Effect.gen(function* () {
  129. const handle = yield* spawner.spawn(command)
  130. const [stdout, stderr, exitCode] = yield* Effect.all(
  131. [
  132. collectStream(handle.stdout, options?.maxOutputBytes),
  133. collectStream(handle.stderr, options?.maxErrorBytes),
  134. handle.exitCode,
  135. ],
  136. { concurrency: "unbounded" },
  137. )
  138. return {
  139. command: description,
  140. exitCode,
  141. stdout: stdout.buffer,
  142. stderr: stderr.buffer,
  143. stdoutTruncated: stdout.truncated,
  144. stderrTruncated: stderr.truncated,
  145. } satisfies RunResult
  146. }),
  147. )
  148. const timed = options?.timeout
  149. ? Effect.timeoutOrElse(collect, {
  150. duration: options.timeout,
  151. orElse: () => Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
  152. })
  153. : collect
  154. const aborted = options?.signal
  155. ? timed.pipe(
  156. Effect.raceFirst(
  157. waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
  158. ),
  159. )
  160. : timed
  161. return aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
  162. }
  163. const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
  164. if (options?.stdin === undefined) return yield* runCommand(command, options)
  165. if (command._tag !== "StandardCommand") {
  166. return yield* new AppProcessError({
  167. command: describeCommand(command),
  168. cause: new Error("stdin option only supports StandardCommand; received PipedCommand"),
  169. })
  170. }
  171. const next = ChildProcess.make(command.command, command.args, {
  172. ...command.options,
  173. stdin: normalizeStdin(options.stdin),
  174. })
  175. return yield* runCommand(next, options)
  176. })
  177. const runStream = (
  178. command: ChildProcess.Command,
  179. options?: RunStreamOptions,
  180. ): Stream.Stream<string, AppProcessError> => {
  181. const description = describeCommand(command)
  182. const okExitCodes = options?.okExitCodes
  183. const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
  184. Effect.gen(function* () {
  185. const handle = yield* spawner.spawn(command)
  186. const stderrFiber = yield* Effect.forkScoped(
  187. collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
  188. )
  189. const source = options?.includeStderr === true ? handle.all : handle.stdout
  190. const lines = source.pipe(
  191. Stream.decodeText,
  192. Stream.splitLines,
  193. Stream.filter((line) => line.length > 0),
  194. )
  195. const tail = Stream.unwrap(
  196. Effect.gen(function* () {
  197. const code = yield* handle.exitCode
  198. if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
  199. const stderr = yield* Fiber.join(stderrFiber)
  200. return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
  201. }
  202. return Stream.empty
  203. }),
  204. )
  205. return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
  206. }),
  207. )
  208. const mapped = built.pipe(
  209. Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
  210. )
  211. if (!options?.signal) return mapped
  212. const signal = options.signal
  213. return mapped.pipe(
  214. Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
  215. )
  216. }
  217. return Service.of({ ...spawner, run, runStream })
  218. }),
  219. )
  220. export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
  221. export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
  222. export * as AppProcess from "./process"