1import { ConfigError } from "effect/Config";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import { FileSystem } from "effect/FileSystem";
6import { envKeys, logLoadedKeys } from "./Log.ts";
7import {
8 resolveSecretsOption,
9 type SecretsLayer,
10 type SecretsOption,
11} from "./Provider.ts";
12
13export interface DotEnvOptions {
14 /**
15 * File or ordered files to load. Later files override earlier files.
16 * Every explicitly supplied path must exist; an empty array loads no files.
17 * @default ".env"
18 */
19 path?: string | readonly string[];
20 /**
21 * Expand `${VAR}` references within the file.
22 * @default false
23 */
24 expandVariables?: boolean;
25}
26
27/** A `secrets` entry that loads dotenv files. */
28export class DotEnvProvider extends Data.TaggedClass(
29 "alchemy/SecretProvider::DotEnv",
30)<{ readonly layer: SecretsLayer }> {}
31
32/**
33 * Add dotenv files to Effect's ConfigProvider without touching `process.env`.
34 *
35 * Keys missing from every file fall back to the previous provider; an empty
36 * value in a file is a real value and overrides it. With no explicit `path`,
37 * a missing `.env` is silently ignored.
38 *
39 * Options may be a callback so a stack can pick files by stage:
40 *
41 * ```ts
42 * secrets: [Secrets.DotEnv(({ stage }) => ({ path: [".env", `.env.${stage}`] }))]
43 * ```
44 */
45export const DotEnv = (options: SecretsOption<DotEnvOptions> = {}) =>
46 new DotEnvProvider({
47 layer: ConfigProvider.layerAdd(
48 Effect.gen(function* () {
49 const resolved = yield* resolveSecretsOption(options);
50 const fs = yield* FileSystem;
51
52 // The implicit default `.env` may be absent; a file named
53 // explicitly must exist.
54 const explicit = resolved.path !== undefined;
55 const path = resolved.path ?? ".env";
56 const paths = typeof path === "string" ? [path] : path;
57
58 let loaded = ConfigProvider.fromEnv({ env: {} });
59 for (const path of paths) {
60 const file = yield* Effect.gen(function* () {
61 if (!explicit && !(yield* fs.exists(path))) return undefined;
62 return yield* ConfigProvider.fromDotEnv({
63 path,
64 expandVariables: resolved.expandVariables,
65 preserveEmptyStrings: true,
66 });
67 }).pipe(
68 Effect.mapError(
69 (cause) =>
70 new ConfigError(
71 new ConfigProvider.SourceError({
72 message: `Failed to read dotenv file ${path}`,
73 cause,
74 }),
75 ),
76 ),
77 );
78 if (file !== undefined) loaded = ConfigProvider.orElse(file, loaded);
79 }
80 yield* logLoadedKeys(
81 `dotenv (${paths.join(", ") || "no files"})`,
82 yield* envKeys(loaded),
83 );
84 return loaded;
85 }),
86 { asPrimary: true },
87 ),
88 });