1import type { ConfigError } from "effect/Config";
2import { ConfigProvider } from "effect/ConfigProvider";
3import * as Context from "effect/Context";
4import type { Crypto } from "effect/Crypto";
5import * as Effect from "effect/Effect";
6import { FileSystem } from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
9import * as Option from "effect/Option";
10import { Path } from "effect/Path";
11import * as Scope from "effect/Scope";
12import * as FetchHttpClient from "effect/http/FetchHttpClient";
13import type { HttpClient } from "effect/http/HttpClient";
14import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
15import type { ActionLike } from "./Action.ts";
16import { AlchemyContext, AlchemyContextLive } from "./AlchemyContext.ts";
17import { type ArtifactStore, provideFreshArtifactStore } from "./Artifacts.ts";
18import { AuthProviders } from "./Auth/AuthProvider.ts";
19import { CredentialsStore, CredentialsStoreLive } from "./Auth/Credentials.ts";
20import { ProfileStore, ProfileStoreLive } from "./Auth/Profile.ts";
21// Type-only: with verbatimModuleSyntax a value import would survive emit and
22// drag the terminal helpers (node:tty, Sigil's ansi helpers) into every unbundled
23// child process that loads Stack.ts.
24import type { Interaction } from "./Interaction.ts";
25import type { Input, InputProps } from "./Input.ts";
26import * as Output from "./Output.ts";
27import type { Provider, ProviderCollectionLike } from "./Provider.ts";
28import type { ResourceBinding, ResourceLike } from "./Resource.ts";
29import { Stage } from "./Stage.ts";
30import { StackContext } from "./StackContext.ts";
31import type { State } from "./State/State.ts";
32import type { SecretsEntry, SecretsOption } from "./Secrets/Provider.ts";
33import { loadConfigProvider, stackConfigLayer } from "./Util/ConfigProvider.ts";
34import { effectClass, taggedFunction } from "./Util/effect.ts";
35import { fileLogger } from "./Util/FileLogger.ts";
36import { PlatformServices } from "./Util/PlatformServices.ts";
37
38export type StackServices =
39 | Stack
40 | Stage
41 | Scope.Scope
42 | FileSystem
43 | Crypto
44 | Path
45 | AlchemyContext
46 | HttpClient
47 | ChildProcessSpawner
48 | AuthProviders
49 | ProfileStore
50 | ArtifactStore
51 | CredentialsStore
52 | Interaction;
53
54export type ProviderServices =
55 | ProviderCollectionLike
56 | Provider<any>
57 | EnvironmentLike
58 | CredentialsLike
59 | DockerLike;
60
61// tagged type to allow types like AWSEnvironment/AWS Region to bubble through
62export interface EnvironmentLike {
63 readonly kind: "Environment";
64}
65
66// tagged type to allow types like AWS Credentials to bubble through
67export interface CredentialsLike {
68 readonly kind: "Credentials";
69}
70
71export interface DockerLike {
72 readonly key: "@alchemy/Docker";
73}
74
75export type StackEffect<A, Err = never, Req = never> = Effect.Effect<
76 A,
77 Err,
78 | PlatformServices
79 | HttpClient
80 | Scope.Scope
81 | AuthProviders
82 | AlchemyContext
83 | Interaction
84 | ProfileStore
85 | CredentialsStore
86 | ArtifactStore
87 | State
88 | Req
89>;
90
91export type Stack = Context.ServiceClass.Shape<
92 "Stack",
93 Omit<StackSpec, "output">
94>;
95
96/**
97 * Where a stack's configuration comes from: a ConfigProvider layer, or a
98 * list of them applied in order, later ones overriding earlier ones.
99 *
100 * The process environment implicitly closes every list, so the shell
101 * overrides every provider. List `Secrets.ProcessEnv()` yourself to rank it
102 * elsewhere, or `Secrets.ProcessEnv({ disabled: true })` to leave it out.
103 * Omitting `secrets` preserves the existing ConfigProvider and CLI dotenv behavior.
104 *
105 * ```ts
106 * secrets: [
107 * Doppler.Secrets({ project: "app", config: "dev" }),
108 * Secrets.DotEnv(),
109 * Secrets.ProcessEnv(),
110 * ]
111 * ```
112 */
113export type SecretProviders = SecretsEntry | ReadonlyArray<SecretsEntry>;
114
115/**
116 * The `secrets` option: providers, or a callback that picks them from the
117 * stage, so one stack can read `.env` locally and a secrets manager
118 * elsewhere:
119 *
120 * ```ts
121 * secrets: ({ stage }) =>
122 * stage === "dev"
123 * ? Secrets.DotEnv()
124 * : Doppler.Secrets({ project: "app", config: stage }),
125 * ```
126 */
127export type StackSecrets = SecretsOption<SecretProviders>;
128
129export interface StackProps<Req> {
130 providers: Layer.Layer<Extract<Req, ProviderServices>, never, StackServices>;
131 state: Layer.Layer<State, never, StackServices>;
132 /** Opt in to secret providers. When omitted, existing configuration is preserved. */
133 secrets?: StackSecrets;
134}
135
136/**
137 * Runtime fields the Stack factory attaches to a configured stack effect.
138 * Class-reference forms expose only `stackName` until `.make(...)`.
139 */
140export type ConfiguredStackMeta<Req = never> = {
141 readonly stackName: string;
142} & StackProps<Req>;
143
144export const Stack: Context.ServiceClass<
145 Stack,
146 "Stack",
147 Omit<StackSpec, "output">
148> & {
149 make<A, Req>(
150 stack: {
151 Shape: A;
152 },
153 effect: Effect.Effect<
154 NoInfer<A extends object ? InputProps<A> : Input<A>>,
155 ConfigError,
156 Req
157 >,
158 ): Effect.Effect<CompiledStack<A>, ConfigError>;
159 <Self>(): {
160 <A, Req>(
161 stackName: string,
162 options: StackProps<NoInfer<Req>>,
163 eff: Effect.Effect<A, ConfigError, Req>,
164 ): Effect.Effect<Self, ConfigError> & {
165 new (_: never): A extends object ? A : {};
166 readonly stackName: string;
167 stage: {
168 [stage: string]: Effect.Effect<Self>;
169 };
170 };
171 };
172 <Self, Shape>(): {
173 (stackName: string): Effect.Effect<Self> & {
174 new (_: never): Output.ToOutput<Shape>;
175 readonly stackName: string;
176 make: <A, Req>(
177 options: StackProps<NoInfer<Req>>,
178 effect: Effect.Effect<A, ConfigError, Req>,
179 ) => Effect.Effect<CompiledStack<A>, ConfigError> &
180 ConfiguredStackMeta<NoInfer<Req>>;
181 stage: {
182 [stage: string]: Effect.Effect<Self>;
183 };
184 };
185 };
186 <A, Req extends StackServices | ProviderServices = never>(
187 stackName: string,
188 options: StackProps<NoInfer<Req>>,
189 eff: Effect.Effect<A, ConfigError, Req>,
190 ): Effect.Effect<CompiledStack<A>, ConfigError> &
191 ConfiguredStackMeta<NoInfer<Req>>;
192} = Object.assign(
193 taggedFunction(
194 StackContext,
195 <A, Req>(
196 stackName?: string,
197 options?: StackProps<NoInfer<Req>>,
198 eff?: Effect.Effect<A, ConfigError, Req>,
199 ) => {
200 if (!stackName) {
201 return (stackName: string) =>
202 Object.assign(
203 // by default, reference the stack at the "current" stage of the importer
204 Output.stackRef<A>(stackName).pipe(effectClass),
205 {
206 stackName,
207 stage: createStageProxy(stackName),
208 state: options?.state,
209 providers: options?.providers,
210 secrets: options?.secrets,
211 make: <Req = never>(
212 options: StackProps<NoInfer<Req>>,
213 eff: Effect.Effect<A, ConfigError, Req>,
214 ) =>
215 // @ts-expect-error
216 Stack(stackName, options, eff),
217 },
218 );
219 }
220 return eff!.pipe(
221 // @ts-expect-error
222 make({
223 name: stackName,
224 ...options!,
225 }),
226 (eff) =>
227 Object.assign(eff, {
228 stackName,
229 stage: createStageProxy(stackName),
230 state: options?.state,
231 providers: options?.providers,
232 secrets: options?.secrets,
233 }),
234 );
235 },
236 ),
237) as any;
238
239const createStageProxy = (stackName: string) =>
240 new Proxy(
241 {},
242 {
243 get: (_, stage: string) => Output.stackRef(stackName, { stage }),
244 },
245 );
246
247export interface StackSpec<Output = any> {
248 name: string;
249 stage: string;
250 // @internal
251 resources: {
252 [logicalId: string]: ResourceLike;
253 };
254 bindings: {
255 [logicalId: string]: ResourceBinding[];
256 };
257 /** Tasks registered on the stack, keyed by FQN. */
258 actions: {
259 [logicalId: string]: ActionLike;
260 };
261 output: Output;
262}
263
264export interface CompiledStack<
265 Output = any,
266 Services = any,
267> extends StackSpec<Output> {
268 services: Context.Context<Services>;
269}
270
271export const StackName = Stack.use((stack) => Effect.succeed(stack.name));
272
273export interface MakeStackProps<ROut = never> {
274 name: string;
275 providers: Layer.Layer<ROut, never, StackServices>;
276 state: Layer.Layer<State, never, StackServices>;
277 /** Opt in to secret providers. When omitted, existing configuration is preserved. */
278 secrets?: StackSecrets;
279 /** @internal */
280 stack?: StackSpec;
281}
282
283export const make =
284 <ROut = never>(options: MakeStackProps<ROut>) =>
285 <A, Err = never, Req extends ROut | StackServices = never>(
286 effect: Effect.Effect<A, Err, Req>,
287 ) =>
288 Effect.scope.pipe(
289 Effect.flatMap((scope) => {
290 if (options.state == null) {
291 return Effect.die(
292 new Error(
293 `Stack "${options.name}" is missing a state store. ` +
294 `Add a \`state\` layer to the stack options, e.g.:\n` +
295 ` Alchemy.Stack("${options.name}", {\n` +
296 ` providers: Cloudflare.providers(),\n` +
297 ` state: Cloudflare.state(), // <-- required\n` +
298 ` }, ...)\n` +
299 `See https://alchemy.run/state-store for available state stores.`,
300 ),
301 );
302 }
303 if (options.providers == null) {
304 return Effect.die(
305 new Error(
306 `Stack "${options.name}" is missing a providers layer. ` +
307 `Add a \`providers\` layer to the stack options, e.g.:\n` +
308 ` Alchemy.Stack("${options.name}", {\n` +
309 ` providers: Cloudflare.providers(), // <-- required\n` +
310 ` state: Cloudflare.state(),\n` +
311 ` }, ...)`,
312 ),
313 );
314 }
315 return options.providers.pipe(
316 Layer.provideMerge(options.state),
317 Layer.provideMerge(stackConfigLayer(options.secrets)),
318 Layer.provideMerge(
319 Layer.effect(
320 Stack,
321 Stage.pipe(
322 Effect.map(
323 (stage) =>
324 options.stack ?? {
325 name: options.name,
326 stage,
327 resources: {},
328 bindings: {},
329 actions: {},
330 },
331 ),
332 ),
333 ),
334 ),
335 Layer.buildWithScope(scope),
336 );
337 }),
338 Effect.flatMap((context) =>
339 Effect.all([
340 effect,
341 Stack,
342 Effect.context<ROut | StackServices>(),
343 ]).pipe(
344 Effect.map(
345 ([output, stack, services]): CompiledStack<
346 A,
347 ROut | StackServices
348 > => ({
349 ...stack,
350 output,
351 services: Context.merge(services, context),
352 }),
353 ),
354 Effect.provideContext(context),
355 ),
356 ),
357 );
358
359export const CurrentStack = Effect.serviceOption(Stack)
360
361 .pipe(Effect.map(Option.getOrUndefined));
362
363const platform = Layer.mergeAll(
364 PlatformServices,
365 FetchHttpClient.layer,
366 Logger.layer([fileLogger("out")], { mergeWithExisting: true }),
367 Layer.provide(ProfileStoreLive, PlatformServices),
368 Layer.provide(CredentialsStoreLive, PlatformServices),
369);
370// override alchemy state store, CLI/reporting, state, and Config
371const alchemy = (overrides?: { dev?: boolean }) =>
372 Layer.mergeAll(
373 // optional
374 overrides?.dev
375 ? Layer.provide(
376 Layer.effect(
377 AlchemyContext,
378 AlchemyContext.useSync((ctx) => ({ ...ctx, dev: overrides.dev! })),
379 ),
380 AlchemyContextLive,
381 )
382 : AlchemyContextLive,
383 );
384
385export const evalStack = <A, B, StackErr, Err, Req>(
386 effect: StackEffect<CompiledStack<A>, StackErr, Stage | AlchemyContext>,
387 fn: (stack: CompiledStack<A>) => Effect.Effect<B, Err, Req>,
388 options: {
389 stage: string;
390 dev?: boolean;
391 /**
392 * Optional caller-supplied scope. When provided, scoped resources
393 * (e.g. the dev sidecar process) live until the caller closes this
394 * scope instead of being torn down when `evalStack` resolves. Used
395 * by the test harness so the sidecar survives across `beforeAll`,
396 * tests, and `afterAll`. When omitted, behaves as before with a
397 * private scope closed on completion.
398 */
399 scope?: Scope.Scope;
400 },
401) => {
402 const body = Effect.gen(function* () {
403 const stack = yield* effect;
404 const configProvider = yield* loadConfigProvider(Option.none());
405 return yield* fn(stack).pipe(
406 provideFreshArtifactStore,
407 Effect.provide(
408 Layer.succeedContext(stack.services).pipe(
409 Layer.provideMerge(Layer.succeed(ConfigProvider, configProvider)),
410 ),
411 ),
412 );
413 }).pipe(
414 Effect.provide(
415 Layer.effect(
416 AuthProviders,
417 Effect.serviceOption(AuthProviders).pipe(
418 Effect.map(Option.getOrElse(() => ({}))),
419 ),
420 ).pipe(
421 Layer.provideMerge(Layer.succeed(Stage, options.stage)),
422 Layer.provideMerge(
423 Layer.provideMerge(alchemy({ dev: options.dev }), platform),
424 ),
425 ),
426 ),
427 );
428
429 return options.scope === undefined
430 ? Effect.scoped(body)
431 : Scope.provide(body, options.scope);
432};