1import * as Cause from "effect/Cause";
2import * as Config from "effect/Config";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as FetchHttpClient from "effect/http/FetchHttpClient";
12import { watchImport } from "@alchemy.run/node-utils/watch-import";
13import { trackBunImports } from "@alchemy.run/node-utils/watch-import-bun";
14import { fileURLToPath } from "node:url";
15
16import { AlchemyContextLive } from "../AlchemyContext.ts";
17import { resolveStackEntrypoint } from "../Alchemist/Entrypoint.ts";
18import { StackModuleLoader } from "../Alchemist/Session.ts";
19import { ArtifactStore, createArtifactStore } from "../Artifacts.ts";
20import { CredentialsStoreLive } from "../Auth/Credentials.ts";
21import { ProfileStoreLive } from "../Auth/Profile.ts";
22import { makeDevLogOpener } from "../Local/DevLog.ts";
23import * as RpcProviderProxy from "../Local/RpcProviderProxy.ts";
24import { forwardSidecarLogs } from "../Local/RpcSpawner.ts";
25import { TelemetryLive } from "../Telemetry/Layer.ts";
26import { initialCwd } from "../Util/Node.ts";
27import { PlatformServices } from "../Util/PlatformServices.ts";
28import * as Stacks from "../Alchemist/routes/stack.ts";
29import { DEV_RELOAD_EXIT_CODE, DevOptions } from "./DevOptions.ts";
30import { ConsoleLogLive } from "./GlobalLog.ts";
31import { handleCliErrors, installShutdownFeedback } from "./commands/errors.ts";
32import { renderApply, renderPlanning } from "./commands/render.ts";
33import * as CliKit from "./CliKit/index.ts";
34import { stackOutputsView } from "./components/view/StackOutputs.tsx";
35import { selectCliServices } from "./selectCli.ts";
36
37// Interactive dev/deploy runs use the Sigil progress UI; CI, redirected output,
38// and other non-interactive terminals still select the append-only renderer.
39// `ALCHEMY_TUI` remains the explicit override in either direction.
40const services = Layer.mergeAll(
41 Layer.provideMerge(
42 Layer.mergeAll(selectCliServices(), CliKit.CliKitInteraction),
43 CliKit.layer(),
44 ),
45 RpcProviderProxy.fromEnv(),
46 Layer.succeed(ArtifactStore, createArtifactStore()),
47 // Dev runs live in this exec child, not the `alchemy` CLI process, so
48 // without this layer they'd export no telemetry at all. No root
49 // `cli.dev` span though: dev remains alive across reloads, so a wrapping
50 // span would not end (and export) until shutdown — plan/apply spans are the
51 // trace roots instead.
52 //
53 // Telemetry is layered *on top of* the console logger so the OTLP logger
54 // merges with it. As `mergeAll` siblings the last `CurrentLoggers` wins,
55 // and telemetry would replace the console logger — every `Effect.log*`
56 // (plan, apply progress, stack outputs) silently vanished from the
57 // terminal whenever telemetry was enabled.
58 Layer.provideMerge(TelemetryLive, ConsoleLogLive),
59).pipe(
60 Layer.provideMerge(
61 Layer.mergeAll(AlchemyContextLive, ProfileStoreLive, CredentialsStoreLive),
62 ),
63 Layer.provideMerge(
64 Layer.mergeAll(
65 PlatformServices,
66 FetchHttpClient.layer,
67 ConfigProvider.layer(ConfigProvider.fromEnv()),
68 ),
69 ),
70);
71
72/** `alchemy dev` normally parks forever; set for single-pass runs (tests). */
73const devOnce = Config.String("ALCHEMY_DEV_ONCE").pipe(
74 Config.withDefault(""),
75 Effect.map((value) => value === "1" || value === "true"),
76);
77
78const runDev = Effect.fn(function* (options: DevOptions) {
79 const target = {
80 entrypoint: options.main,
81 stage: options.stage,
82 profile: options.profile,
83 envFile: Option.getOrUndefined(options.envFile),
84 };
85 const snapshot = yield* Stacks.plan({
86 target,
87 operation: "deploy",
88 force: options.force,
89 include: options.include,
90 exclude: options.exclude,
91 updateStateStore: true,
92 dev: true,
93 }).pipe(renderPlanning({ operation: "Dev", stage: options.stage }));
94 const once = yield* devOnce;
95 const applyPlan = Stacks.apply(snapshot).pipe(
96 renderApply(snapshot.native, {
97 stage: options.stage,
98 dev: !once,
99 }),
100 );
101 const result = yield* once
102 ? applyPlan
103 : applyPlan.pipe(
104 Effect.catchCause((cause) =>
105 Console.error(
106 describeFailure(
107 "apply",
108 cause,
109 "keeping dev alive so healthy resources keep serving",
110 ),
111 ).pipe(Effect.as(undefined)),
112 ),
113 );
114 if (result !== undefined && once) {
115 const kit = yield* CliKit.CliKit;
116 yield* kit.output.print(stackOutputsView(result));
117 }
118 return once ? undefined : yield* Effect.never;
119});
120
121/**
122 * Resolves with the files behind the next debounced change batch. The
123 * listener detaches itself on delivery — `Effect.callback` only runs its
124 * cleanup on interruption, so a listener left behind would fire once per
125 * past generation on every later change.
126 */
127const nextChange = (watcher: {
128 subscribe: (
129 listener: (change: { paths: ReadonlySet<string> }) => void,
130 ) => () => void;
131}) =>
132 Effect.callback<ReadonlySet<string>>((resume) => {
133 const unsubscribe = watcher.subscribe(({ paths }) => {
134 unsubscribe();
135 resume(Effect.succeed(paths));
136 });
137 return Effect.sync(unsubscribe);
138 });
139
140const logReload = Effect.fn(function* (paths: ReadonlySet<string>) {
141 const path = yield* Path.Path;
142 yield* Effect.logInfo(
143 `Reloading stack: changed ${[...paths]
144 .map((file) => path.relative(initialCwd, file))
145 .join(", ")}`,
146 );
147});
148
149/**
150 * Node: one process, many generations. The Oxc loader imports the stack graph
151 * under a fresh namespace each time; a change to any file it loaded
152 * interrupts the parked run (whose scope tears down the dev widget) and the
153 * loop imports the next generation.
154 */
155const runNodeDevWatcher = Effect.fn(function* (options: DevOptions) {
156 const fs = yield* FileSystem.FileSystem;
157 const path = yield* Path.Path;
158 // Node reports real paths for loaded files (symlinked checkouts, macOS
159 // `/tmp`), so the project root the graph is scoped to must be real too.
160 // Scope to the invocation directory, not the entrypoint's directory: a
161 // config under `infra/` commonly imports application code from `src/`.
162 const entrypoint = yield* fs.realPath(
163 yield* resolveStackEntrypoint(options.main),
164 );
165 const root = yield* fs.realPath(initialCwd);
166 const nodeModules = `${path.sep}node_modules${path.sep}`;
167 return yield* Effect.acquireRelease(
168 Effect.sync(() =>
169 watchImport<{ readonly default?: unknown }>(entrypoint, {
170 parentURL: import.meta.url,
171 shouldInvalidate: (url) => {
172 if (!url.startsWith("file:")) return false;
173 const file = fileURLToPath(url);
174 const relative = path.relative(root, file);
175 return (
176 !file.includes(nodeModules) &&
177 (relative === "" ||
178 (relative !== ".." &&
179 !relative.startsWith(`..${path.sep}`) &&
180 !path.isAbsolute(relative)))
181 );
182 },
183 }),
184 ),
185 (watcher) => Effect.promise(() => watcher.close()),
186 ).pipe(
187 Effect.flatMap((watcher) => {
188 const generation = devKeepAlive(runDev(options)).pipe(
189 Effect.provideService(StackModuleLoader, {
190 import: () => watcher.import().then(({ value }) => value),
191 }),
192 Effect.scoped,
193 );
194 return Effect.forever(
195 Effect.raceFirst(generation, nextChange(watcher)).pipe(
196 Effect.flatMap((paths) =>
197 paths === undefined ? Effect.void : logReload(paths),
198 ),
199 ),
200 );
201 }),
202 );
203});
204
205/**
206 * Bun: one process per generation. Bun cannot evict evaluated modules, so the
207 * tracker only records which project files the stack graph loads; the first
208 * change to one of them logs, lets the generation scope tear down the dev
209 * widget, and exits with DEV_RELOAD_EXIT_CODE for the supervisor to respawn.
210 */
211const runBunDevWatcher = (options: DevOptions) =>
212 Effect.acquireRelease(
213 Effect.sync(() =>
214 // Match Node's project-wide graph: the stack entrypoint may live in a
215 // subdirectory while importing sibling source trees.
216 trackBunImports({ root: initialCwd }),
217 ),
218 (tracker) => Effect.promise(() => tracker.close()),
219 ).pipe(
220 Effect.flatMap((tracker) =>
221 Effect.raceFirst(
222 devKeepAlive(runDev(options)).pipe(Effect.scoped),
223 nextChange(tracker),
224 ),
225 ),
226 Effect.flatMap((paths) =>
227 paths === undefined
228 ? Effect.void
229 : logReload(paths).pipe(
230 Effect.andThen(
231 Effect.sync(() => {
232 process.exitCode = DEV_RELOAD_EXIT_CODE;
233 }),
234 ),
235 ),
236 ),
237 );
238
239/**
240 * A run that ended by itself with nothing but interruptions in its cause was
241 * cancelled from the inside — a provider or engine race, never the user —
242 * so it must be reported like any other failure. Ctrl+C is different: it
243 * interrupts the fiber running this code, so `catchCause` never gets to
244 * park it (a parked `Effect.never` is interrupted straight away too).
245 */
246const describeFailure = (
247 what: string,
248 cause: Cause.Cause<unknown>,
249 next: string,
250) =>
251 Cause.hasInterruptsOnly(cause)
252 ? `alchemy dev: ${what} was interrupted internally (a bug in a provider or the engine — please report it with the trace below); ${next}.\n${Cause.pretty(cause)}`
253 : `alchemy dev: ${what} failed; ${next}.\n${Cause.pretty(cause)}`;
254
255// A mid-edit import or planning failure must keep the watch process alive so
256// the next save can restart it. Ctrl+C still tears the run down.
257export const devKeepAlive = <A, E, R>(
258 effect: Effect.Effect<A, E, R>,
259): Effect.Effect<A, E, R> =>
260 effect.pipe(
261 Effect.catchCause((cause) =>
262 Console.error(
263 describeFailure(
264 "run",
265 cause,
266 "waiting for the next file change to retry",
267 ),
268 ).pipe(Effect.andThen(Effect.never)),
269 ),
270 );
271
272const makeExec = () => {
273 const options = Schema.decodeSync(DevOptions)(
274 JSON.parse(process.env.ALCHEMY_EXEC_OPTIONS!),
275 );
276 return Effect.gen(function* () {
277 yield* installShutdownFeedback;
278 // Subscribe to the spawner's sidecar log stream BEFORE the stack runs:
279 // this process owns the terminal renderer, so sidecar output printed
280 // here lands in chronological order with the run's own lines instead of
281 // racing the shared tty. No-op outside dev. The mixed tail is also teed
282 // to log/{stage}/{timestamp}.log; per-resource output lands in
283 // log/{stage}/{fqn…}/ via the local providers.
284 const devLog = yield* (yield* makeDevLogOpener)(options.stage);
285 yield* forwardSidecarLogs((entry) =>
286 devLog.writeLine(`[${entry.channel}] ${entry.line}`),
287 );
288 // Single-pass runs park nothing. Otherwise Node reloads the stack graph in
289 // this process; Bun exits for the supervisor to respawn (see above).
290 return yield* (yield* devOnce)
291 ? devKeepAlive(runDev(options))
292 : process.versions.bun !== undefined
293 ? runBunDevWatcher(options)
294 : runNodeDevWatcher(options);
295 }).pipe(Effect.provide(services), Effect.scoped, handleCliErrors);
296};
297
298/** Fully wired sidecar CLI program. */
299export const exec: () => Effect.Effect<
300 void,
301 Effect.Error<ReturnType<typeof makeExec>>
302> = makeExec;