| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Config from "effect/Config"; |
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 4 | import * as Console from "effect/Console"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 12 | import { watchImport } from "@alchemy.run/node-utils/watch-import"; |
| 13 | import { trackBunImports } from "@alchemy.run/node-utils/watch-import-bun"; |
| 14 | import { fileURLToPath } from "node:url"; |
| 15 | |
| 16 | import { AlchemyContextLive } from "../AlchemyContext.ts"; |
| 17 | import { resolveStackEntrypoint } from "../Alchemist/Entrypoint.ts"; |
| 18 | import { StackModuleLoader } from "../Alchemist/Session.ts"; |
| 19 | import { ArtifactStore, createArtifactStore } from "../Artifacts.ts"; |
| 20 | import { CredentialsStoreLive } from "../Auth/Credentials.ts"; |
| 21 | import { ProfileStoreLive } from "../Auth/Profile.ts"; |
| 22 | import { makeDevLogOpener } from "../Local/DevLog.ts"; |
| 23 | import * as RpcProviderProxy from "../Local/RpcProviderProxy.ts"; |
| 24 | import { forwardSidecarLogs } from "../Local/RpcSpawner.ts"; |
| 25 | import { TelemetryLive } from "../Telemetry/Layer.ts"; |
| 26 | import { initialCwd } from "../Util/Node.ts"; |
| 27 | import { PlatformServices } from "../Util/PlatformServices.ts"; |
| 28 | import * as Stacks from "../Alchemist/routes/stack.ts"; |
| 29 | import { DEV_RELOAD_EXIT_CODE, DevOptions } from "./DevOptions.ts"; |
| 30 | import { ConsoleLogLive } from "./GlobalLog.ts"; |
| 31 | import { handleCliErrors, installShutdownFeedback } from "./commands/errors.ts"; |
| 32 | import { renderApply, renderPlanning } from "./commands/render.ts"; |
| 33 | import * as CliKit from "./CliKit/index.ts"; |
| 34 | import { stackOutputsView } from "./components/view/StackOutputs.tsx"; |
| 35 | import { 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. |
| 40 | const 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). */ |
| 73 | const devOnce = Config.String("ALCHEMY_DEV_ONCE").pipe( |
| 74 | Config.withDefault(""), |
| 75 | Effect.map((value) => value === "1" || value === "true"), |
| 76 | ); |
| 77 | |
| 78 | const 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 | */ |
| 127 | const 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 | |
| 140 | const 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 | */ |
| 155 | const 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 | */ |
| 211 | const 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 | */ |
| 246 | const 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. |
| 257 | export 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 | |
| 272 | const 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. */ |
| 299 | export const exec: () => Effect.Effect< |
| 300 | void, |
| 301 | Effect.Error<ReturnType<typeof makeExec>> |
| 302 | > = makeExec; |