1import { ConfigError } from "effect/Config";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import { ProcessEnv } from "../Secrets/ProcessEnv.ts";
9import { resolveSecretsOption } from "../Secrets/Provider.ts";
10import type { StackSecrets, StackServices } from "../Stack.ts";
11
12/**
13 * Command-line settings that shape a stack's configuration.
14 *
15 * @internal
16 */
17export interface StackConfigOverrides {
18 /**
19 * The `--env-file` flag: the file the CLI reads instead of
20 * `.env`. A stack that declares its own `secrets` cannot be combined with
21 * it; the list is the single place that decides what loads.
22 */
23 readonly envFile?: string;
24 /** The `--profile` flag. */
25 readonly profile?: string;
26}
27
28/** @internal */
29export const StackConfigOverrides = Context.Reference<StackConfigOverrides>(
30 "Alchemy/StackConfigOverrides",
31 { defaultValue: () => ({}) },
32);
33
34/** Report a secrets provider's own failure as the ConfigError a stack fails with. */
35const toConfigError = (error: unknown) =>
36 error instanceof ConfigError
37 ? error
38 : new ConfigError(
39 new ConfigProvider.SourceError({
40 message: error instanceof Error ? error.message : String(error),
41 cause: error,
42 }),
43 );
44
45/**
46 * Build the ConfigProvider a stack runs under from its `secrets` list.
47 * When omitted, leave the caller's configuration unchanged.
48 *
49 * Each provider is a layer that requires the configuration assembled so far
50 * and provides the merged result, so the list folds with `Layer.provide`:
51 * later providers override earlier ones. With an explicit list, `ALCHEMY_PROFILE` is taken
52 * from `--profile` or the real process environment only, never from a
53 * provider, so a dotenv file or a secrets manager cannot redirect which
54 * credentials every other provider uses.
55 *
56 * @internal
57 */
58export const stackConfigLayer = (
59 secrets?: StackSecrets,
60): Layer.Layer<never, ConfigError, StackServices> =>
61 secrets === undefined
62 ? Layer.empty
63 : Layer.effect(
64 ConfigProvider.ConfigProvider,
65 Effect.gen(function* () {
66 const overrides = yield* StackConfigOverrides;
67 if (overrides.envFile !== undefined) {
68 return yield* Effect.fail(
69 new ConfigError(
70 new ConfigProvider.SourceError({
71 message: `--env-file cannot be combined with a stack that declares \`secrets\`. Add Secrets.DotEnv({ path: "${overrides.envFile}" }) to the list instead, or drop the flag.`,
72 }),
73 ),
74 );
75 }
76
77 const declared = yield* resolveSecretsOption(secrets);
78
79 // Every list ends with the shell unless the user placed (or disabled) it.
80 const entries = Array.isArray(declared) ? [...declared] : [declared];
81 const shellListed = entries.some(
82 (entry) =>
83 "_tag" in entry &&
84 entry._tag === "alchemy/SecretProvider::ProcessEnv",
85 );
86 if (!shellListed) entries.push(ProcessEnv());
87
88 // `ALCHEMY_PROFILE` is answered from `--profile` or the real shell,
89 // never from a provider. Every provider is built on a pinned view (so
90 // its credentials resolve against the right profile) and the stack
91 // gets a pinned view of the final result.
92 const profile =
93 overrides.profile ??
94 (yield* Effect.sync(
95 () => process.env.ALCHEMY_PROFILE || undefined,
96 ));
97 const pinned = ConfigProvider.fromEnv({
98 env: profile === undefined ? {} : { ALCHEMY_PROFILE: profile },
99 });
100 const pin = (provider: ConfigProvider.ConfigProvider) =>
101 ConfigProvider.make((path) =>
102 (path[0] === "ALCHEMY_PROFILE" ? pinned : provider).load(path),
103 );
104
105 const chain = entries.reduce<
106 Layer.Layer<never, unknown, StackServices>
107 >(
108 (below, entry) =>
109 ("layer" in entry ? entry.layer : entry).pipe(
110 // A reused entry must merge with this position's configuration,
111 // not return the result memoized at its first position.
112 Layer.fresh,
113 Layer.provide(
114 ConfigProvider.layer(
115 Effect.map(ConfigProvider.ConfigProvider, pin),
116 ).pipe(Layer.provide(below)),
117 ),
118 ),
119 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
120 );
121 const context = yield* Layer.build(chain).pipe(
122 Effect.mapError(toConfigError),
123 );
124 return pin(Context.get(context, ConfigProvider.ConfigProvider));
125 }),
126 );
127
128/**
129 * The ConfigProvider a CLI command runs under before any stack is loaded:
130 * an explicit `--env-file` overrides the process environment. Without that
131 * flag, the process environment overrides an optional `.env` file.
132 */
133export const loadConfigProvider = Effect.fn("loadConfigProvider")(function* (
134 envFile: Option.Option<string>,
135) {
136 const fs = yield* FileSystem.FileSystem;
137
138 let path: string | undefined;
139 if (Option.isSome(envFile)) {
140 path = envFile.value;
141 } else if (yield* fs.exists(".env")) {
142 path = ".env";
143 }
144
145 // `fromEnv` snapshots `process.env` when called, so build it here rather
146 // than once at module load.
147 const shell = ConfigProvider.fromEnv();
148 if (path === undefined) return shell;
149 const dotEnv = yield* ConfigProvider.fromDotEnv({
150 path,
151 });
152 return Option.isSome(envFile)
153 ? ConfigProvider.orElse(dotEnv, shell)
154 : ConfigProvider.orElse(shell, dotEnv);
155});