1import * as Effect from "effect/Effect";
2import { havePropsChanged, isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { CommandExecutor, type CommandRunProps } from "./Command.ts";
6import { hashDirectory, type MemoOptions } from "./Memo.ts";
7
8export interface ExecProps extends CommandRunProps {
9 /**
10 * Controls which files are hashed to decide whether the command should
11 * re-run. By default every non-gitignored file in `cwd` is hashed, plus the
12 * nearest lockfile. Provide explicit globs to narrow the scope, or set
13 * `false` to disable memoization and re-run on every deploy.
14 *
15 * @see {@link MemoOptions}
16 * @default true
17 */
18 memo?: MemoOptions | boolean;
19}
20
21export interface Exec extends Resource<
22 "Command.Exec",
23 ExecProps,
24 {
25 /**
26 * Hash of the input files for this command, if memoization is enabled.
27 */
28 hash: {
29 input: string | undefined;
30 };
31 }
32> {}
33
34/**
35 * An `Exec` runs a shell command purely for its side effects — it has no
36 * output contract. Unlike `Build`, it does not produce or track an output
37 * asset; `reconcile` runs the command and the resource succeeds as long as the
38 * command exits with code `0` (a non-zero exit fails with a `CommandError`).
39 *
40 * Use it for one-off setup steps — running migrations, seeding data, code
41 * generation, or any command whose result lives outside Alchemy's state. By
42 * default the input files are content-hashed so the command only re-runs when
43 * its inputs (or `command`/`cwd`/`env`) change; set `memo: false` to re-run on
44 * every deploy.
45 *
46 * ### Running a Command
47 * **Example:** Run a One-Off Command
48 * ```typescript
49 * yield* Exec("codegen", {
50 * command: "npm run codegen",
51 * cwd: "./packages/api",
52 * });
53 * ```
54 *
55 * ### Running with Custom Environment
56 * **Example:** Run Database Migrations
57 * ```typescript
58 * yield* Exec("migrate", {
59 * command: "npm run db:migrate",
60 * env: {
61 * DATABASE_URL: Redacted.make("postgres://..."),
62 * },
63 * });
64 * ```
65 *
66 * ### Memoizing Re-Runs
67 * **Example:** Only Re-Run When Inputs Change
68 * ```typescript
69 * yield* Exec("codegen", {
70 * command: "npm run codegen",
71 * memo: { include: ["schema/**"] },
72 * });
73 * ```
74 *
75 * ### Bounding Command Runtime
76 * **Example:** Time Out a Migration
77 * ```typescript
78 * yield* Exec("migrate", {
79 * command: "npm run db:migrate",
80 * timeout: "5 minutes",
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const Exec = Resource<Exec>("Command.Exec");
87
88export const ExecProvider = () =>
89 Provider.effect(
90 Exec,
91 Effect.gen(function* () {
92 const { run } = yield* CommandExecutor;
93
94 return {
95 list: () => Effect.succeed([]),
96 diff: Effect.fn(function* ({ olds, news, output }) {
97 if (!output || !isResolved(news)) return undefined;
98
99 // Always update if memoization is disabled or input hash is not available.
100 if (news.memo === false || !output.hash.input)
101 return { action: "update" };
102
103 // Optimization: short-circuit if props have changed to avoid unnecessary file system operations.
104 if (havePropsChanged(olds, news)) return { action: "update" };
105
106 const newHash = yield* hashDirectory({
107 cwd: news.cwd,
108 memo: news.memo === true ? {} : news.memo,
109 });
110 return {
111 action: newHash === output.hash.input ? "noop" : "update",
112 };
113 }),
114 reconcile: Effect.fn(function* ({ news, session }) {
115 yield* run(news, session);
116 return {
117 hash: {
118 input:
119 news.memo === false
120 ? undefined
121 : yield* hashDirectory({
122 cwd: news.cwd,
123 memo: news.memo === true ? {} : news.memo,
124 }),
125 },
126 };
127 }),
128 delete: () => Effect.void,
129 };
130 }),
131 );