1import * as Cause from "effect/Cause";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";
11import * as Path from "effect/Path";
12import * as Predicate from "effect/Predicate";
13import * as Schema from "effect/Schema";
14import * as Semaphore from "effect/Semaphore";
15import { pathToFileURL } from "node:url";
16import { AdoptPolicy } from "../AdoptPolicy.ts";
17import { AlchemyContext } from "../AlchemyContext.ts";
18import { ArtifactStore, createArtifactStore } from "../Artifacts.ts";
19import { AuthError, AuthProviders } from "../Auth/AuthProvider.ts";
20import { SuppressMissingProviderConfig } from "../Auth/Profile.ts";
21import { withProfileOverride } from "../Auth/Resolve.ts";
22import { AwsAuth } from "../AWS/AuthProvider.ts";
23import { AxiomAuth } from "../Axiom/AuthProvider.ts";
24import { CloudflareAuth } from "../Cloudflare/Auth/AuthProvider.ts";
25import { FlyAuth } from "../Fly/AuthProvider.ts";
26import { DopplerAuth } from "../Doppler/AuthProvider.ts";
27import { GcpAuth } from "../GCP/AuthProvider.ts";
28import { GitHubAuth } from "../GitHub/AuthProvider.ts";
29import { HetznerAuth } from "../Hetzner/AuthProvider.ts";
30import { InfisicalAuth } from "../Infisical/AuthProvider.ts";
31import { NeonAuth } from "../Neon/AuthProvider.ts";
32import { PlanetscaleAuth } from "../Planetscale/AuthProvider.ts";
33import { PrismaAuth } from "../Prisma/AuthProvider.ts";
34import { RailwayAuth } from "../Railway/AuthProvider.ts";
35import { StripeAuth } from "../Stripe/AuthProvider.ts";
36import * as Stack from "../Stack.ts";
37import { Stage } from "../Stage.ts";
38import { Progress } from "./Progress.ts";
39import {
40 loadConfigProvider,
41 stackConfigLayer,
42 StackConfigOverrides,
43} from "../Util/ConfigProvider.ts";
44import { fileLogger } from "../Util/FileLogger.ts";
45
46import {
47 DEFAULT_ENTRYPOINT,
48 resolveStackEntrypoint,
49 StackEntrypointError,
50} from "./Entrypoint.ts";
51
52export { DEFAULT_ENTRYPOINT, resolveStackEntrypoint, StackEntrypointError };
53
54/** Stage used by routes that inspect a project without addressing a deployment. */
55export const PLACEHOLDER_STAGE = "placeholder";
56
57/**
58 * The default export of an `alchemy.run.ts` entrypoint: the stack effect
59 * `Alchemy.Stack(...)` returns, carrying the metadata the factory attaches.
60 */
61export type StackModule = ReturnType<ReturnType<typeof Stack.make>> & {
62 readonly stackName: string;
63 readonly providers: Layer.Layer<never> | undefined;
64 readonly state: Layer.Layer<never> | undefined;
65 readonly secrets: Stack.StackSecrets | undefined;
66};
67
68export interface StackModuleLoader {
69 readonly import: (url: string) => Promise<{ readonly default?: unknown }>;
70}
71
72export const StackModuleLoader = Context.Reference<StackModuleLoader>(
73 "Alchemy/Alchemist/StackModuleLoader",
74 {
75 defaultValue: () => ({ import: (url) => import(url) }),
76 },
77);
78
79export const importStack = Effect.fn(function* (main: string) {
80 const absolutePath = yield* resolveStackEntrypoint(main);
81 const loader = yield* StackModuleLoader;
82 const module = yield* Effect.promise(() =>
83 loader.import(pathToFileURL(absolutePath).href),
84 );
85 if (
86 !Effect.isEffect(module.default) ||
87 !Predicate.hasProperty(module.default, "stackName") ||
88 typeof module.default.stackName !== "string"
89 ) {
90 return yield* Effect.fail(
91 new StackEntrypointError({
92 message: `Stack entrypoint '${main}' must export a default stack definition (export default Alchemy.Stack({...})).`,
93 }),
94 );
95 }
96 return module.default as StackModule;
97});
98
99/**
100 * Which project, stage, and credentials a route acts under. Every route that
101 * touches the user's program takes one of these; the optional fields fall back
102 * to the same defaults the CLI uses.
103 */
104export interface Target {
105 /** Stack entrypoint. @default "alchemy.run.ts" */
106 readonly entrypoint?: string;
107 /** Stage the run addresses. @default "placeholder" (routes that never deploy) */
108 readonly stage?: string;
109 /** Profile whose credentials the run uses. Falls back to the active profile. */
110 readonly profile?: string;
111 /** `.env` file layered under the process environment. */
112 readonly envFile?: string;
113}
114
115/** A {@link Target} for a route that genuinely acts on one stack instance. */
116export interface StackTarget extends Target {
117 readonly entrypoint: string;
118 readonly stage: string;
119}
120
121export interface OpenOptions {
122 /** Run local (emulated) providers instead of the real cloud. */
123 readonly dev?: boolean;
124 /** Adopt pre-existing cloud resources instead of failing on conflict. */
125 readonly adopt?: boolean;
126 /** Upgrade an out-of-date state store without prompting. */
127 readonly updateStateStore?: boolean;
128}
129
130interface SessionServicesOptions {
131 readonly envFile: Option.Option<string>;
132 readonly profile?: string;
133 readonly logger?: Layer.Layer<never, never, never>;
134 readonly extra?: Layer.Layer<never, never, never>;
135}
136
137/** Shared config and logging services used by every stack/auth build path. */
138const sessionServices = Effect.fn("sessionServices")(function* (
139 options: SessionServicesOptions,
140) {
141 const commandConfig = yield* loadConfigProvider(options.envFile);
142
143 // `--env-file` only steers a stack's default secrets list and `--profile`
144 // pins the profile inside it; see stackConfigLayer.
145 return Layer.mergeAll(
146 Layer.succeed(StackConfigOverrides, {
147 envFile: Option.getOrUndefined(options.envFile),
148 profile: options.profile,
149 }),
150 ConfigProvider.layer(withProfileOverride(commandConfig, options.profile)),
151 options.logger ??
152 Logger.layer([fileLogger("out")], { mergeWithExisting: true }),
153 options.extra ?? Layer.empty,
154 );
155});
156
157interface RouteCacheService {
158 readonly open: (
159 target: Target,
160 options: OpenOptions,
161 ) => Effect.Effect<
162 Session,
163 Effect.Error<ReturnType<typeof openUncached>>,
164 Effect.Services<ReturnType<typeof openUncached>>
165 >;
166 readonly authProviders: (
167 options: CollectAuthProvidersOptions,
168 ) => Effect.Effect<
169 AuthProviders["Service"],
170 Effect.Error<ReturnType<typeof collectAuthProvidersUncached>>,
171 Effect.Services<ReturnType<typeof collectAuthProvidersUncached>>
172 >;
173}
174
175export interface CollectAuthProvidersOptions {
176 readonly main: string;
177 readonly envFile: Option.Option<string>;
178 readonly profile: string;
179}
180
181const RouteCache = Context.Reference<RouteCacheService>(
182 "Alchemy/Alchemist/RouteCache",
183 {
184 defaultValue: () => ({
185 open: (target, options) => openUncached(target, options),
186 authProviders: collectAuthProvidersUncached,
187 }),
188 },
189);
190
191const stackSessionKey = (target: Target, options: OpenOptions) =>
192 JSON.stringify([
193 target.entrypoint ?? DEFAULT_ENTRYPOINT,
194 target.stage ?? PLACEHOLDER_STAGE,
195 target.profile ?? null,
196 target.envFile ?? null,
197 options.dev ?? false,
198 options.adopt ?? false,
199 options.updateStateStore ?? false,
200 ]);
201
202const authRegistryKey = (options: CollectAuthProvidersOptions) =>
203 JSON.stringify([
204 options.main,
205 Option.getOrNull(options.envFile),
206 options.profile,
207 ]);
208
209const cached = <A, E, R>(
210 lock: Semaphore.Semaphore,
211 entries: Map<string, Effect.Effect<A, E, R>>,
212 key: string,
213 load: Effect.Effect<A, E, R>,
214) =>
215 Semaphore.withPermits(
216 lock,
217 1,
218 )(
219 Effect.suspend(() => {
220 const existing = entries.get(key);
221 if (existing !== undefined) return Effect.succeed(existing);
222 return Effect.cached(load).pipe(
223 Effect.tap((effect) =>
224 Effect.sync(() => {
225 entries.set(key, effect);
226 }),
227 ),
228 );
229 }),
230 ).pipe(Effect.flatten);
231
232/** Memoize stack sessions and auth registries for one command or API scope. */
233export const routeCacheLayer = Layer.effect(
234 RouteCache,
235 Effect.sync((): RouteCacheService => {
236 const lock = Semaphore.makeUnsafe(1);
237 const sessions = new Map<string, ReturnType<typeof openUncached>>();
238 const registries = new Map<
239 string,
240 ReturnType<typeof collectAuthProvidersUncached>
241 >();
242 return {
243 open: (target, options) =>
244 cached(
245 lock,
246 sessions,
247 stackSessionKey(target, options),
248 openUncached(target, options),
249 ),
250 authProviders: (options) =>
251 cached(
252 lock,
253 registries,
254 authRegistryKey(options),
255 collectAuthProvidersUncached(options),
256 ),
257 };
258 }),
259);
260
261/**
262 * Import the user's stack module and build the services its resources run
263 * under. This is the one place that decides what a stack run sees — plan,
264 * apply, drift, logs, and state inspection all come through here.
265 *
266 * The session lives in the surrounding `Scope`; run stack-scoped effects by
267 * providing `session.context` (`Effect.provide(effect, session.context)`).
268 */
269const openUncached = Effect.fn("openStackSessionUncached")(function* (
270 target: Target,
271 options: OpenOptions = {},
272) {
273 // Phases are emitted here, at the actual work boundaries, rather than by
274 // callers around `open` — the import and the service build both happen
275 // inside this function, so only it can time them honestly.
276 const report = yield* Progress;
277 yield* report({ _tag: "plan.phase", phase: "importing-module" });
278 const stackEffect = yield* importStack(
279 target.entrypoint ?? DEFAULT_ENTRYPOINT,
280 );
281 yield* report({ _tag: "plan.phase", phase: "resolving-services" });
282 const shared = yield* sessionServices({
283 envFile: Option.fromNullishOr(target.envFile),
284 profile: target.profile,
285 });
286 const services = Layer.mergeAll(
287 Layer.effect(
288 AlchemyContext,
289 Effect.map(AlchemyContext, (context) => ({
290 ...context,
291 dev: options.dev ?? context.dev,
292 adopt: options.adopt ?? false,
293 updateStateStore: options.updateStateStore ?? false,
294 })),
295 ),
296 Layer.succeedContext(
297 Context.make(AdoptPolicy, options.adopt ?? false).pipe(
298 Context.add(ArtifactStore, createArtifactStore()),
299 Context.add(
300 AuthProviders,
301 yield* Effect.serviceOption(AuthProviders).pipe(
302 Effect.map(Option.getOrElse((): AuthProviders["Service"] => ({}))),
303 ),
304 ),
305 Context.add(Stage, target.stage ?? PLACEHOLDER_STAGE),
306 ),
307 ),
308 shared,
309 );
310 const sessionContext = yield* Layer.build(services);
311 const stack = yield* Effect.provide(stackEffect, sessionContext);
312 return {
313 stack,
314 /**
315 * Everything a stack-scoped effect runs under: the stack's own providers
316 * and state store over this session's services.
317 */
318 context: Context.merge(sessionContext, stack.services),
319 };
320});
321
322/** An imported, fully-configured stack instance. */
323export type Session = Effect.Success<ReturnType<typeof openUncached>>;
324
325export const open = Effect.fn("openStackSession")(function* (
326 target: Target,
327 options: OpenOptions = {},
328) {
329 return yield* (yield* RouteCache).open(target, options);
330});
331
332const placeholderStack = (name: string) => ({
333 actions: {},
334 bindings: {},
335 name,
336 resources: {},
337 stage: PLACEHOLDER_STAGE,
338});
339
340export interface BuildStackProvidersOptions {
341 readonly main: string;
342 readonly envFile: Option.Option<string>;
343 readonly profile?: string;
344 readonly registry?: AuthProviders["Service"];
345 readonly logger?: Layer.Layer<never, never, never>;
346 readonly extra?: Layer.Layer<never, never, never>;
347}
348
349export const buildStackProviders = Effect.fn("buildStackProviders")(function* (
350 options: BuildStackProvidersOptions,
351) {
352 const authProviders = options.registry ?? {};
353 const stackEffect = yield* importStack(options.main);
354 const shared = yield* sessionServices(options);
355 const valueServices = Layer.succeedContext(
356 Context.make(AuthProviders, authProviders).pipe(
357 Context.add(Stage, PLACEHOLDER_STAGE),
358 Context.add(Stack.Stack, placeholderStack(stackEffect.stackName)),
359 ),
360 );
361 const context = yield* Layer.build(
362 (stackEffect.providers ?? Layer.empty).pipe(
363 Layer.provideMerge(stackEffect.state ?? Layer.empty),
364 Layer.provideMerge(stackConfigLayer(stackEffect.secrets)),
365 Layer.provideMerge(Layer.mergeAll(valueServices, shared)),
366 ),
367 );
368 return { authProviders, context, stackEffect };
369});
370
371const builtinAuth = Layer.mergeAll(
372 AwsAuth,
373 AxiomAuth,
374 CloudflareAuth,
375 FlyAuth,
376 GcpAuth,
377 GitHubAuth,
378 DopplerAuth,
379 HetznerAuth,
380 InfisicalAuth,
381 NeonAuth,
382 PlanetscaleAuth,
383 PrismaAuth,
384 RailwayAuth,
385 StripeAuth,
386);
387
388const buildBuiltinAuthProviders = Effect.fn("buildBuiltinAuthProviders")(
389 function* (options: {
390 readonly envFile: Option.Option<string>;
391 readonly profile: string;
392 readonly registry?: AuthProviders["Service"];
393 }) {
394 const authProviders = options.registry ?? {};
395 const shared = yield* sessionServices(options);
396 yield* Layer.build(
397 Layer.provide(
398 builtinAuth,
399 Layer.mergeAll(Layer.succeed(AuthProviders, authProviders), shared),
400 ),
401 );
402 return authProviders;
403 },
404);
405
406const isMissingProviderConfig = Schema.is(
407 Schema.Struct({ _tag: Schema.Literals(["MissingProviderConfig"]) }),
408);
409
410/**
411 * Every auth provider reachable from the current project: the built-ins, plus
412 * whatever the user's stack module registers when it is importable.
413 */
414const collectAuthProvidersUncached = Effect.fn("collectAuthProvidersUncached")(
415 function* (options: CollectAuthProvidersOptions) {
416 const authProviders: AuthProviders["Service"] = {};
417 yield* buildBuiltinAuthProviders({
418 envFile: options.envFile,
419 profile: options.profile,
420 registry: authProviders,
421 });
422
423 const fs = yield* FileSystem.FileSystem;
424 const entrypointExists = yield* fs.exists(options.main);
425 const missingDefault =
426 options.main === DEFAULT_ENTRYPOINT && !entrypointExists;
427 if (!entrypointExists && !missingDefault) {
428 return yield* Effect.fail(
429 new AuthError({
430 message: `Stack entrypoint '${options.main}' does not exist.`,
431 }),
432 );
433 }
434 if (!missingDefault) {
435 yield* buildStackProviders({ ...options, registry: authProviders }).pipe(
436 Effect.timeoutOption(Duration.seconds(15)),
437 Effect.catchCause((cause) => {
438 const suppressed = cause.reasons.some((reason) => {
439 const error = Cause.isFailReason(reason)
440 ? reason.error
441 : Cause.isDieReason(reason)
442 ? reason.defect
443 : undefined;
444 return isMissingProviderConfig(error);
445 });
446 return suppressed
447 ? Effect.void
448 : Effect.fail(
449 new AuthError({
450 message: `Could not load auth providers from '${options.main}'.`,
451 cause,
452 }),
453 );
454 }),
455 );
456 }
457 return authProviders;
458 },
459 Effect.provideService(SuppressMissingProviderConfig, true),
460);
461
462export const collectAuthProviders = Effect.fn("collectAuthProviders")(
463 function* (options: CollectAuthProvidersOptions) {
464 return yield* (yield* RouteCache).authProviders(options);
465 },
466);