process.ts 8.6 KB

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