process.ts 8.5 KB

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