| 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Logger from "effect/Logger"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | import * as Predicate from "effect/Predicate"; |
| 13 | import * as Schema from "effect/Schema"; |
| 14 | import * as Semaphore from "effect/Semaphore"; |
| 15 | import { pathToFileURL } from "node:url"; |
| 16 | import { AdoptPolicy } from "../AdoptPolicy.ts"; |
| 17 | import { AlchemyContext } from "../AlchemyContext.ts"; |
| 18 | import { ArtifactStore, createArtifactStore } from "../Artifacts.ts"; |
| 19 | import { AuthError, AuthProviders } from "../Auth/AuthProvider.ts"; |
| 20 | import { SuppressMissingProviderConfig } from "../Auth/Profile.ts"; |
| 21 | import { withProfileOverride } from "../Auth/Resolve.ts"; |
| 22 | import { AwsAuth } from "../AWS/AuthProvider.ts"; |
| 23 | import { AxiomAuth } from "../Axiom/AuthProvider.ts"; |
| 24 | import { CloudflareAuth } from "../Cloudflare/Auth/AuthProvider.ts"; |
| 25 | import { FlyAuth } from "../Fly/AuthProvider.ts"; |
| 26 | import { DopplerAuth } from "../Doppler/AuthProvider.ts"; |
| 27 | import { GcpAuth } from "../GCP/AuthProvider.ts"; |
| 28 | import { GitHubAuth } from "../GitHub/AuthProvider.ts"; |
| 29 | import { HetznerAuth } from "../Hetzner/AuthProvider.ts"; |
| 30 | import { InfisicalAuth } from "../Infisical/AuthProvider.ts"; |
| 31 | import { NeonAuth } from "../Neon/AuthProvider.ts"; |
| 32 | import { PlanetscaleAuth } from "../Planetscale/AuthProvider.ts"; |
| 33 | import { PrismaAuth } from "../Prisma/AuthProvider.ts"; |
| 34 | import { RailwayAuth } from "../Railway/AuthProvider.ts"; |
| 35 | import { StripeAuth } from "../Stripe/AuthProvider.ts"; |
| 36 | import * as Stack from "../Stack.ts"; |
| 37 | import { Stage } from "../Stage.ts"; |
| 38 | import { Progress } from "./Progress.ts"; |
| 39 | import { |
| 40 | loadConfigProvider, |
| 41 | stackConfigLayer, |
| 42 | StackConfigOverrides, |
| 43 | } from "../Util/ConfigProvider.ts"; |
| 44 | import { fileLogger } from "../Util/FileLogger.ts"; |
| 45 | |
| 46 | import { |
| 47 | DEFAULT_ENTRYPOINT, |
| 48 | resolveStackEntrypoint, |
| 49 | StackEntrypointError, |
| 50 | } from "./Entrypoint.ts"; |
| 51 | |
| 52 | export { DEFAULT_ENTRYPOINT, resolveStackEntrypoint, StackEntrypointError }; |
| 53 | |
| 54 | /** Stage used by routes that inspect a project without addressing a deployment. */ |
| 55 | export 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 | */ |
| 61 | export 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 | |
| 68 | export interface StackModuleLoader { |
| 69 | readonly import: (url: string) => Promise<{ readonly default?: unknown }>; |
| 70 | } |
| 71 | |
| 72 | export const StackModuleLoader = Context.Reference<StackModuleLoader>( |
| 73 | "Alchemy/Alchemist/StackModuleLoader", |
| 74 | { |
| 75 | defaultValue: () => ({ import: (url) => import(url) }), |
| 76 | }, |
| 77 | ); |
| 78 | |
| 79 | export 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 | */ |
| 104 | export 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. */ |
| 116 | export interface StackTarget extends Target { |
| 117 | readonly entrypoint: string; |
| 118 | readonly stage: string; |
| 119 | } |
| 120 | |
| 121 | export 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 | |
| 130 | interface 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. */ |
| 138 | const 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 | |
| 157 | interface 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 | |
| 175 | export interface CollectAuthProvidersOptions { |
| 176 | readonly main: string; |
| 177 | readonly envFile: Option.Option<string>; |
| 178 | readonly profile: string; |
| 179 | } |
| 180 | |
| 181 | const 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 | |
| 191 | const 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 | |
| 202 | const authRegistryKey = (options: CollectAuthProvidersOptions) => |
| 203 | JSON.stringify([ |
| 204 | options.main, |
| 205 | Option.getOrNull(options.envFile), |
| 206 | options.profile, |
| 207 | ]); |
| 208 | |
| 209 | const 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. */ |
| 233 | export 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 | */ |
| 269 | const 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. */ |
| 323 | export type Session = Effect.Success<ReturnType<typeof openUncached>>; |
| 324 | |
| 325 | export const open = Effect.fn("openStackSession")(function* ( |
| 326 | target: Target, |
| 327 | options: OpenOptions = {}, |
| 328 | ) { |
| 329 | return yield* (yield* RouteCache).open(target, options); |
| 330 | }); |
| 331 | |
| 332 | const placeholderStack = (name: string) => ({ |
| 333 | actions: {}, |
| 334 | bindings: {}, |
| 335 | name, |
| 336 | resources: {}, |
| 337 | stage: PLACEHOLDER_STAGE, |
| 338 | }); |
| 339 | |
| 340 | export 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 | |
| 349 | export 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 | |
| 371 | const 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 | |
| 388 | const 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 | |
| 406 | const 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 | */ |
| 414 | const 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 | |
| 462 | export const collectAuthProviders = Effect.fn("collectAuthProviders")( |
| 463 | function* (options: CollectAuthProvidersOptions) { |
| 464 | return yield* (yield* RouteCache).authProviders(options); |
| 465 | }, |
| 466 | ); |