process.ts 7.5 KB

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