1/**
2 * Contains props, error tags, and the live service for executing commands.
3 * Shared between the `Build`, `Dev`, and `Exec` resources.
4 */
5
6import * as Context from "effect/Context";
7import * as Data from "effect/Data";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import { flow } from "effect/Function";
12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import type { PlatformError } from "effect/PlatformError";
16import { BadArgument, SystemError } from "effect/PlatformError";
17import * as Predicate from "effect/Predicate";
18import * as Redacted from "effect/Redacted";
19import type * as Scope from "effect/Scope";
20import { initialCwd } from "../Util/Node.ts";
21import * as Sink from "effect/Sink";
22import * as Stream from "effect/Stream";
23import * as ChildProcess from "effect/process/ChildProcess";
24import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
25import type { ScopedPlanStatusSession } from "../Report.ts";
26import { isNonInteractive } from "../Util/interactive.ts";
27import {
28 makeCommandRedactor,
29 redactPlatformReason,
30 type CommandRedactor,
31} from "./Redaction.ts";
32
33/**
34 * Base properties for a command resource.
35 */
36export interface CommandProps {
37 /**
38 * The command to run.
39 */
40 command: string;
41 /**
42 * Working directory for the command. Defaults to `process.cwd()`.
43 */
44 cwd?: string;
45 /**
46 * If set to true, runs the command inside of a shell, defaulting to /bin/sh on UNIX systems and cmd.exe on Windows.
47 * It is generally discouraged to use this option, as it can lead to security vulnerabilities and is not portable.
48 * If set to a string, runs the command inside of the specified shell.
49 * @default false
50 */
51 shell?: string | boolean;
52 /**
53 * Extra environment variables passed to the command on top of `process.env`.
54 * Entries whose value is `undefined` are dropped — this lets website
55 * composites forward optional values (e.g. an `Output` service URL that
56 * resolves to `undefined`) without filtering first.
57 */
58 env?: Record<string, string | Redacted.Redacted<string> | undefined>;
59}
60
61/**
62 * Properties shared by finite commands such as {@link Build} and
63 * {@link Exec}.
64 */
65export interface CommandRunProps extends CommandProps {
66 /**
67 * Maximum time the command may run. When exceeded, Alchemy sends `SIGTERM`
68 * to the command's process group, waits a bounded grace period, and then
69 * sends `SIGKILL` to ensure no descendants survive.
70 *
71 * Numbers are interpreted as milliseconds. Effect duration strings and
72 * `Duration` values are also accepted.
73 *
74 * @example "5 minutes"
75 */
76 timeout?: Duration.Input;
77}
78
79export class CommandExecutor extends Context.Service<
80 CommandExecutor,
81 {
82 /**
83 * Spawns a command, returning the child process handle.
84 */
85 readonly spawn: (
86 props: CommandProps,
87 ) => Effect.Effect<
88 ChildProcessSpawner.ChildProcessHandle,
89 CommandError,
90 Scope.Scope
91 >;
92 /**
93 * Executes a command, returning the exit code, stdout, and stderr.
94 * Throws a {@link CommandError} if the command exits with a non-zero exit code.
95 */
96 readonly run: (
97 props: CommandRunProps,
98 session: ScopedPlanStatusSession,
99 ) => Effect.Effect<
100 { exitCode: number; stdout: string; stderr: string },
101 CommandError
102 >;
103 }
104>()("alchemy/Command/CommandExecutor") {}
105
106/**
107 * Extends Effect's `PlatformError` to include the command that failed and some command-specific error reasons.
108 */
109export class CommandError extends Data.TaggedError("CommandError")<{
110 command: string;
111 reason:
112 | SystemError
113 | BadArgument
114 | UnexpectedExit
115 | OutputNotFound
116 | CommandTimedOut;
117 cause?: unknown;
118}> {
119 constructor({
120 command,
121 reason,
122 }: {
123 command: string;
124 reason: CommandError["reason"];
125 }) {
126 if ("cause" in reason) {
127 super({ command, reason, cause: reason.cause });
128 } else {
129 super({ command, reason });
130 }
131 }
132
133 override get message() {
134 return `Failed to execute command "${this.command}": ${this.reason.message}`;
135 }
136}
137
138export const isCommandError = (error: unknown): error is CommandError =>
139 Predicate.isTagged(error, "CommandError");
140
141/**
142 * Represents when a command exits unexpectedly.
143 */
144export class UnexpectedExit extends Data.TaggedError("UnexpectedExit")<{
145 exitCode: number;
146 stderr: string;
147}> {
148 override get message() {
149 return `The command exited with code ${this.exitCode}. Standard error output: ${this.stderr}`;
150 }
151}
152
153/**
154 * Represents when a finite command exceeds its configured timeout.
155 */
156export class CommandTimedOut extends Data.TaggedError("CommandTimedOut")<{
157 timeout: string;
158}> {
159 override get message() {
160 return `The command timed out after ${this.timeout}; its process group was terminated.`;
161 }
162}
163
164/**
165 * Represents when the output directory does not exist.
166 */
167export class OutputNotFound extends Data.TaggedError("OutputNotFound")<{
168 outdir: string;
169}> {
170 override get message() {
171 return `The output directory "${this.outdir}" does not exist.`;
172 }
173}
174
175/** @internal */
176export const makeCommandError = (
177 props: Pick<CommandProps, "command" | "env">,
178 reason: CommandError["reason"],
179): CommandError => {
180 const redactor = makeCommandRedactor(props.env);
181 const safeReason =
182 reason instanceof BadArgument || reason instanceof SystemError
183 ? redactPlatformReason(reason, redactor)
184 : reason._tag === "UnexpectedExit"
185 ? new UnexpectedExit({
186 exitCode: reason.exitCode,
187 stderr: redactor.redact(reason.stderr),
188 })
189 : reason._tag === "OutputNotFound"
190 ? new OutputNotFound({ outdir: redactor.redact(reason.outdir) })
191 : new CommandTimedOut({
192 timeout: redactor.redact(reason.timeout),
193 });
194
195 return new CommandError({
196 command: redactor.redact(props.command),
197 reason: safeReason,
198 });
199};
200
201const TERMINATION_GRACE_PERIOD = Duration.seconds(1);
202const TERMINATION_SIGNAL_DISPATCH_TIMEOUT = Duration.millis(25);
203
204const parseExecutionTimeout = (
205 props: CommandRunProps,
206): Effect.Effect<Duration.Duration | undefined, CommandError> => {
207 if (props.timeout === undefined) return Effect.succeed(undefined);
208 const decoded = Duration.fromInput(props.timeout);
209 if (
210 Option.isNone(decoded) ||
211 !Duration.isFinite(decoded.value) ||
212 Duration.isZero(decoded.value) ||
213 Duration.isNegative(decoded.value)
214 ) {
215 return Effect.fail(
216 makeCommandError(
217 props,
218 new BadArgument({
219 module: "Command",
220 method: "run",
221 description: "timeout must be a positive finite duration",
222 }),
223 ),
224 );
225 }
226 return Effect.succeed(decoded.value);
227};
228
229const terminateProcessGroup = (child: ChildProcessSpawner.ChildProcessHandle) =>
230 Effect.gen(function* () {
231 // Dispatch SIGTERM without waiting indefinitely for the root process. The
232 // Effect process runtime targets the detached process group on POSIX and
233 // the full process tree on Windows.
234 yield* child
235 .kill({ killSignal: "SIGTERM" })
236 .pipe(
237 Effect.timeoutOption(TERMINATION_SIGNAL_DISPATCH_TIMEOUT),
238 Effect.ignore,
239 );
240 yield* Effect.sleep(TERMINATION_GRACE_PERIOD);
241
242 // Always signal the group again: the root may have exited promptly while
243 // a descendant ignored SIGTERM. The hard-kill wait is itself bounded.
244 yield* child
245 .kill({ killSignal: "SIGKILL" })
246 .pipe(Effect.timeoutOption(TERMINATION_GRACE_PERIOD), Effect.ignore);
247 });
248
249const redactChildProcessHandle = (
250 child: ChildProcessSpawner.ChildProcessHandle,
251 redactor: CommandRedactor,
252): ChildProcessSpawner.ChildProcessHandle => {
253 const stdout = child.stdout.pipe(
254 Stream.decodeText,
255 redactor.stream,
256 Stream.encodeText,
257 );
258 const stderr = child.stderr.pipe(
259 Stream.decodeText,
260 redactor.stream,
261 Stream.encodeText,
262 );
263 return ChildProcessSpawner.makeHandle({
264 pid: child.pid,
265 exitCode: child.exitCode,
266 isRunning: child.isRunning,
267 kill: child.kill,
268 stdin: child.stdin,
269 stdout,
270 stderr,
271 all: Stream.merge(stdout, stderr),
272 getInputFd: child.getInputFd,
273 getOutputFd: child.getOutputFd,
274 unref: child.unref,
275 });
276};
277
278export const CommandExecutorLive = () =>
279 Layer.effect(
280 CommandExecutor,
281 Effect.gen(function* () {
282 const path = yield* Path.Path;
283 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
284
285 /** Parses a command string into a binary and arguments, unless {@link CommandProps.shell} is true. */
286 const parseCommand = (
287 props: CommandProps,
288 ): Effect.Effect<{ bin: string; args: string[] }, CommandError> => {
289 if (props.shell) {
290 return Effect.succeed({ bin: props.command, args: [] });
291 }
292 const [bin, ...args] = props.command
293 .split(/(\s+)/)
294 .filter((part) => !!part.trim());
295 if (!bin) {
296 return Effect.fail(
297 makeCommandError(
298 props,
299 new BadArgument({
300 module: "Command",
301 method: "parseCommand",
302 description: "Command is empty",
303 }),
304 ),
305 );
306 }
307 return Effect.succeed({ bin, args });
308 };
309
310 /** Spawns a command, returning the child process handle. */
311 const spawn = (props: CommandProps) =>
312 parseCommand(props).pipe(
313 Effect.flatMap(({ bin, args }) =>
314 Effect.acquireRelease(
315 spawner.spawn(
316 ChildProcess.make(bin, args, {
317 // Anchored: a live `process.cwd()` read can race a
318 // concurrent tool's transient chdir (see Util/Node.ts).
319 cwd: path.resolve(initialCwd, props.cwd ?? "."),
320 shell: props.shell ?? false,
321 env: Object.fromEntries(
322 Object.entries(props.env ?? {})
323 .filter(
324 (
325 entry,
326 ): entry is [
327 string,
328 string | Redacted.Redacted<string>,
329 ] => entry[1] !== undefined,
330 )
331 .map(([k, v]) => [
332 k,
333 Redacted.isRedacted(v) ? Redacted.value(v) : v,
334 ]),
335 ),
336 extendEnv: true,
337 stdin: isNonInteractive() ? "ignore" : "inherit",
338 stdout: "pipe",
339 stderr: "pipe",
340 // The Effect process runtime creates a detached process group
341 // by default on POSIX. Preserve that default so timeouts and
342 // scoped interruption can terminate every descendant.
343 // Preserve hard cleanup when the leader exits with an error.
344 killSignal: "SIGKILL",
345 }),
346 ),
347 (child) =>
348 child
349 .kill({
350 killSignal: "SIGTERM",
351 forceKillAfter: TERMINATION_GRACE_PERIOD,
352 })
353 .pipe(Effect.ignore),
354 ),
355 ),
356 Effect.map((child) =>
357 redactChildProcessHandle(child, makeCommandRedactor(props.env)),
358 ),
359 mapError(props),
360 );
361
362 /** Collects the output of a stream, tapping each line to a sink. */
363 const collect = (
364 stream: Stream.Stream<Uint8Array, PlatformError>,
365 tap: (chunk: string) => Effect.Effect<void>,
366 redactor: CommandRedactor,
367 ) =>
368 stream.pipe(
369 Stream.decodeText,
370 redactor.stream,
371 Stream.tapSink(
372 Sink.make<string>()(
373 flow(Stream.splitLines, Stream.runForEach(tap)),
374 ),
375 ),
376 Stream.mkString,
377 );
378
379 /** Maps a PlatformError to a CommandError. */
380 const mapError = (props: CommandProps) =>
381 Effect.mapError((error: PlatformError | CommandError) =>
382 error._tag === "CommandError"
383 ? error
384 : makeCommandError(props, error.reason),
385 );
386
387 return CommandExecutor.of({
388 spawn,
389 /** Executes a command, returning the exit code, stdout, and stderr. */
390 run: (props: CommandRunProps, session: ScopedPlanStatusSession) =>
391 Effect.gen(function* () {
392 const timeout = yield* parseExecutionTimeout(props);
393 const redactor = makeCommandRedactor(props.env);
394 const child = yield* spawn(props);
395 const execution = Effect.all(
396 {
397 exitCode: child.exitCode,
398 stdout: collect(
399 child.stdout,
400 (line) => session.note(line, { kind: "output" }),
401 redactor,
402 ),
403 stderr: collect(
404 child.stderr,
405 (line) => session.note(line, { kind: "output" }),
406 redactor,
407 ),
408 },
409 { concurrency: "unbounded" },
410 ).pipe(mapError(props));
411
412 const result =
413 timeout === undefined
414 ? yield* execution
415 : yield* Effect.gen(function* () {
416 const fiber = yield* Effect.forkScoped(execution);
417 const completed = yield* Fiber.join(fiber).pipe(
418 Effect.timeoutOption(timeout),
419 );
420 if (Option.isSome(completed)) return completed.value;
421
422 yield* terminateProcessGroup(child);
423 yield* Fiber.interrupt(fiber).pipe(
424 Effect.timeoutOption(TERMINATION_GRACE_PERIOD),
425 Effect.ignore,
426 );
427 return yield* Effect.fail(
428 makeCommandError(
429 props,
430 new CommandTimedOut({
431 timeout: Duration.format(timeout),
432 }),
433 ),
434 );
435 });
436
437 if (result.exitCode !== 0) {
438 return yield* Effect.fail(
439 makeCommandError(
440 props,
441 new UnexpectedExit({
442 exitCode: result.exitCode,
443 stderr: result.stderr,
444 }),
445 ),
446 );
447 }
448 return result;
449 }).pipe(Effect.scoped),
450 });
451 }),
452 );