1/**
2 * @title Working with child processes
3 *
4 * This example shows how to collect process output, compose pipelines, and stream long-running command output.
5 */
6import { NodeServices } from "@effect/platform-node"
7import { Console, Context, Effect, Layer, Schema, Stream, String } from "effect"
8import { ChildProcess, ChildProcessSpawner } from "effect/process"
9
10export class DevToolsError extends Schema.TaggedError<DevToolsError>()("DevToolsError", {
11 cause: Schema.Defect()
12}) {}
13
14export class DevTools extends Context.Service<DevTools, {
15 readonly nodeVersion: Effect.Effect<string, DevToolsError>
16 readonly recentCommitSubjects: Effect.Effect<ReadonlyArray<string>, DevToolsError>
17 readonly runLintFix: Effect.Effect<void, DevToolsError>
18 changedTypeScriptFiles(baseRef: string): Effect.Effect<ReadonlyArray<string>, DevToolsError>
19}>()("docs/DevTools") {
20 static readonly layer = Layer.effect(
21 DevTools,
22 Effect.gen(function*() {
23 // To run child processes, we need access to a `ChildProcessSpawner`.
24 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
25
26 // Use `spawner.string` when you want to collect the entire output of a
27 // command as a string. This runs `node --version` and collects the
28 // output.
29 const nodeVersion = spawner.string(
30 ChildProcess.make("node", ["--version"])
31 ).pipe(
32 Effect.map(String.trim),
33 Effect.mapError((cause) => new DevToolsError({ cause }))
34 )
35
36 const changedTypeScriptFiles = Effect.fn("DevTools.changedTypeScriptFiles")(function*(baseRef: string) {
37 yield* Effect.annotateCurrentSpan({ baseRef })
38
39 // `spawner.lines` is a convenience helper for line-oriented command
40 // output.
41 const files = yield* spawner.lines(
42 ChildProcess.make("git", ["diff", "--name-only", `${baseRef}...HEAD`])
43 ).pipe(
44 Effect.mapError((cause) => new DevToolsError({ cause }))
45 )
46
47 return files.filter((file) => file.endsWith(".ts"))
48 })
49
50 // Build a pipeline from two command values. This runs:
51 // `git log --pretty=format:%s -n 20 | head -n 5`
52 const recentCommitSubjects = spawner.lines(
53 ChildProcess.make("git", ["log", "--pretty=format:%s", "-n", "20"]).pipe(
54 ChildProcess.pipeTo(ChildProcess.make("head", ["-n", "5"]))
55 )
56 ).pipe(
57 Effect.mapError((cause) => new DevToolsError({ cause }))
58 )
59
60 const runLintFix = Effect.gen(function*() {
61 // Use `spawn` when you want the process handle and stream output while
62 // the process is still running.
63 const handle = yield* spawner.spawn(
64 ChildProcess.make("pnpm", ["lint-fix"], {
65 env: { FORCE_COLOR: "1" },
66 extendEnv: true
67 })
68 ).pipe(
69 Effect.mapError((cause) => new DevToolsError({ cause }))
70 )
71
72 yield* handle.all.pipe(
73 Stream.decodeText(),
74 Stream.splitLines,
75 Stream.runForEach((line) => Console.log(`[lint-fix] ${line}`)),
76 Effect.mapError((cause) => new DevToolsError({ cause }))
77 )
78
79 const exitCode = yield* handle.exitCode.pipe(
80 Effect.mapError((cause) => new DevToolsError({ cause }))
81 )
82
83 if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
84 return yield* new DevToolsError({
85 cause: new Error(`pnpm lint-fix failed with exit code ${exitCode}`)
86 })
87 }
88 }).pipe(
89 // `spawner.spawn` adds a `Scope` requirement to manage the lifecycle of
90 // the child process. We can use `Effect.scoped` to provide a `Scope`
91 // and close it when the effect completes.
92 Effect.scoped
93 )
94
95 return DevTools.of({
96 nodeVersion,
97 changedTypeScriptFiles,
98 recentCommitSubjects,
99 runLintFix
100 })
101 })
102 ).pipe(
103 // Provide the `ChildProcessSpawner` dependency from `NodeServices.layer`.
104 Layer.provide(NodeServices.layer)
105 )
106}
107
108export const program = Effect.gen(function*() {
109 const tools = yield* DevTools
110
111 const version = yield* tools.nodeVersion
112 yield* Effect.log(`node=${version}`)
113}).pipe(
114 // `ChildProcess` requires a platform implementation of
115 // `ChildProcessSpawner`. In Node.js, `NodeServices.layer` provides it.
116 Effect.provide(DevTools.layer)
117)