@karmaniverous/get-dotenv 4.6.0-0 → 5.0.0-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -23
- package/dist/cliHost.cjs +1089 -0
- package/dist/cliHost.d.cts +191 -0
- package/dist/cliHost.d.mts +191 -0
- package/dist/cliHost.d.ts +191 -0
- package/dist/cliHost.mjs +1085 -0
- package/dist/config.cjs +247 -0
- package/dist/config.d.cts +53 -0
- package/dist/config.d.mts +53 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.mjs +242 -0
- package/dist/env-overlay.cjs +163 -0
- package/dist/env-overlay.d.cts +48 -0
- package/dist/env-overlay.d.mts +48 -0
- package/dist/env-overlay.d.ts +48 -0
- package/dist/env-overlay.mjs +161 -0
- package/dist/getdotenv.cli.mjs +2788 -734
- package/dist/index.cjs +902 -280
- package/dist/index.d.cts +122 -64
- package/dist/index.d.mts +122 -64
- package/dist/index.d.ts +122 -64
- package/dist/index.mjs +904 -283
- package/dist/plugins-aws.cjs +618 -0
- package/dist/plugins-aws.d.cts +176 -0
- package/dist/plugins-aws.d.mts +176 -0
- package/dist/plugins-aws.d.ts +176 -0
- package/dist/plugins-aws.mjs +616 -0
- package/dist/plugins-batch.cjs +569 -0
- package/dist/plugins-batch.d.cts +198 -0
- package/dist/plugins-batch.d.mts +198 -0
- package/dist/plugins-batch.d.ts +198 -0
- package/dist/plugins-batch.mjs +567 -0
- package/dist/plugins-init.cjs +282 -0
- package/dist/plugins-init.d.cts +180 -0
- package/dist/plugins-init.d.mts +180 -0
- package/dist/plugins-init.d.ts +180 -0
- package/dist/plugins-init.mjs +280 -0
- package/getdotenv.config.json +19 -0
- package/package.json +88 -17
- package/templates/cli/ts/index.ts +9 -0
- package/templates/cli/ts/plugins/hello.ts +17 -0
- package/templates/config/js/getdotenv.config.js +15 -0
- package/templates/config/json/local/getdotenv.config.local.json +7 -0
- package/templates/config/json/public/getdotenv.config.json +12 -0
- package/templates/config/public/getdotenv.config.json +13 -0
- package/templates/config/ts/getdotenv.config.ts +16 -0
- package/templates/config/yaml/local/getdotenv.config.local.yaml +7 -0
- package/templates/config/yaml/public/getdotenv.config.yaml +10 -0
package/dist/cliHost.mjs
ADDED
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { packageDirectory } from 'package-directory';
|
|
4
|
+
import path, { join, extname } from 'path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import url, { fileURLToPath, pathToFileURL } from 'url';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
import { nanoid } from 'nanoid';
|
|
9
|
+
import { parse } from 'dotenv';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Define a GetDotenv CLI plugin with compositional helpers.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
|
|
17
|
+
* .use(childA)
|
|
18
|
+
* .use(childB);
|
|
19
|
+
*/
|
|
20
|
+
const definePlugin = (spec) => {
|
|
21
|
+
const { children = [], ...rest } = spec;
|
|
22
|
+
const plugin = {
|
|
23
|
+
...rest,
|
|
24
|
+
children: [...children],
|
|
25
|
+
use(child) {
|
|
26
|
+
this.children.push(child);
|
|
27
|
+
return this;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return plugin;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
|
|
34
|
+
const baseRootOptionDefaults = {
|
|
35
|
+
dotenvToken: '.env',
|
|
36
|
+
loadProcess: true,
|
|
37
|
+
logger: console,
|
|
38
|
+
paths: './',
|
|
39
|
+
pathsDelimiter: ' ',
|
|
40
|
+
privateToken: 'local',
|
|
41
|
+
scripts: {
|
|
42
|
+
'git-status': {
|
|
43
|
+
cmd: 'git branch --show-current && git status -s -u',
|
|
44
|
+
shell: true,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
shell: true,
|
|
48
|
+
vars: '',
|
|
49
|
+
varsAssignor: '=',
|
|
50
|
+
varsDelimiter: ' ',
|
|
51
|
+
// tri-state flags default to unset unless explicitly provided
|
|
52
|
+
// (debug/log/exclude* resolved via flag utils)
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const baseGetDotenvCliOptions = baseRootOptionDefaults;
|
|
56
|
+
|
|
57
|
+
/** @internal */
|
|
58
|
+
const isPlainObject = (value) => value !== null &&
|
|
59
|
+
typeof value === 'object' &&
|
|
60
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
61
|
+
const mergeInto = (target, source) => {
|
|
62
|
+
for (const [key, sVal] of Object.entries(source)) {
|
|
63
|
+
if (sVal === undefined)
|
|
64
|
+
continue; // do not overwrite with undefined
|
|
65
|
+
const tVal = target[key];
|
|
66
|
+
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
67
|
+
target[key] = mergeInto({ ...tVal }, sVal);
|
|
68
|
+
}
|
|
69
|
+
else if (isPlainObject(sVal)) {
|
|
70
|
+
target[key] = mergeInto({}, sVal);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
target[key] = sVal;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return target;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Perform a deep defaults-style merge across plain objects. *
|
|
80
|
+
* - Only merges plain objects (prototype === Object.prototype).
|
|
81
|
+
* - Arrays and non-objects are replaced, not merged.
|
|
82
|
+
* - `undefined` values are ignored and do not overwrite prior values.
|
|
83
|
+
*
|
|
84
|
+
* @typeParam T - The resulting shape after merging all layers.
|
|
85
|
+
* @param layers - Zero or more partial layers in ascending precedence order.
|
|
86
|
+
* @returns The merged object typed as {@link T}.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* defaultsDeep(\{ a: 1, nested: \{ b: 2 \} \}, \{ nested: \{ b: 3, c: 4 \} \})
|
|
90
|
+
* =\> \{ a: 1, nested: \{ b: 3, c: 4 \} \}
|
|
91
|
+
*/
|
|
92
|
+
const defaultsDeep = (...layers) => {
|
|
93
|
+
const result = layers
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.reduce((acc, layer) => mergeInto(acc, layer), {});
|
|
96
|
+
return result;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/GetDotenvOptions.ts
|
|
100
|
+
const getDotenvOptionsFilename = 'getdotenv.config.json';
|
|
101
|
+
/**
|
|
102
|
+
* Converts programmatic CLI options to `getDotenv` options. *
|
|
103
|
+
* @param cliOptions - CLI options. Defaults to `{}`.
|
|
104
|
+
*
|
|
105
|
+
* @returns `getDotenv` options.
|
|
106
|
+
*/
|
|
107
|
+
const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
|
|
108
|
+
/**
|
|
109
|
+
* Convert CLI-facing string options into {@link GetDotenvOptions}.
|
|
110
|
+
*
|
|
111
|
+
* - Splits {@link GetDotenvCliOptions.paths} using either a delimiter * or a regular expression pattern into a string array. * - Parses {@link GetDotenvCliOptions.vars} as space-separated `KEY=VALUE`
|
|
112
|
+
* pairs (configurable delimiters) into a {@link ProcessEnv}.
|
|
113
|
+
* - Drops CLI-only keys that have no programmatic equivalent.
|
|
114
|
+
*
|
|
115
|
+
* @remarks
|
|
116
|
+
* Follows exact-optional semantics by not emitting undefined-valued entries.
|
|
117
|
+
*/
|
|
118
|
+
// Drop CLI-only keys (debug/scripts) without relying on Record casts.
|
|
119
|
+
// Create a shallow copy then delete optional CLI-only keys if present.
|
|
120
|
+
const restObj = { ...rest };
|
|
121
|
+
delete restObj.debug;
|
|
122
|
+
delete restObj.scripts;
|
|
123
|
+
const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
|
|
124
|
+
// Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
|
|
125
|
+
let parsedVars;
|
|
126
|
+
if (typeof vars === 'string') {
|
|
127
|
+
const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
|
|
128
|
+
? RegExp(varsAssignorPattern)
|
|
129
|
+
: (varsAssignor ?? '=')));
|
|
130
|
+
parsedVars = Object.fromEntries(kvPairs);
|
|
131
|
+
}
|
|
132
|
+
else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
|
|
133
|
+
// Keep only string or undefined values to match ProcessEnv.
|
|
134
|
+
const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
|
|
135
|
+
parsedVars = Object.fromEntries(entries);
|
|
136
|
+
}
|
|
137
|
+
// Drop undefined-valued entries at the converter stage to match ProcessEnv
|
|
138
|
+
// expectations and the compat test assertions.
|
|
139
|
+
if (parsedVars) {
|
|
140
|
+
parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
|
|
141
|
+
}
|
|
142
|
+
// Tolerate paths as either a delimited string or string[]
|
|
143
|
+
// Use a locally cast union type to avoid lint warnings about always-falsy conditions
|
|
144
|
+
// under the RootOptionsShape (which declares paths as string | undefined).
|
|
145
|
+
const pathsAny = paths;
|
|
146
|
+
const pathsOut = Array.isArray(pathsAny)
|
|
147
|
+
? pathsAny.filter((p) => typeof p === 'string')
|
|
148
|
+
: splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
|
|
149
|
+
// Preserve exactOptionalPropertyTypes: only include keys when defined.
|
|
150
|
+
return {
|
|
151
|
+
...restObj,
|
|
152
|
+
...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
|
|
153
|
+
...(parsedVars !== undefined ? { vars: parsedVars } : {}),
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const resolveGetDotenvOptions = async (customOptions) => {
|
|
157
|
+
/**
|
|
158
|
+
* Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
|
|
159
|
+
*
|
|
160
|
+
* 1. Base defaults derived from the CLI generator defaults
|
|
161
|
+
* ({@link baseGetDotenvCliOptions}).
|
|
162
|
+
* 2. Local project overrides from a `getdotenv.config.json` in the nearest
|
|
163
|
+
* package root (if present).
|
|
164
|
+
* 3. The provided {@link customOptions}.
|
|
165
|
+
*
|
|
166
|
+
* The result preserves explicit empty values and drops only `undefined`.
|
|
167
|
+
*
|
|
168
|
+
* @returns Fully-resolved {@link GetDotenvOptions}.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```ts
|
|
172
|
+
* const options = await resolveGetDotenvOptions({ env: 'dev' });
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
const localPkgDir = await packageDirectory();
|
|
176
|
+
const localOptionsPath = localPkgDir
|
|
177
|
+
? join(localPkgDir, getDotenvOptionsFilename)
|
|
178
|
+
: undefined;
|
|
179
|
+
const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
|
|
180
|
+
? JSON.parse((await fs.readFile(localOptionsPath)).toString())
|
|
181
|
+
: {});
|
|
182
|
+
// Merge order: base < local < custom (custom has highest precedence)
|
|
183
|
+
const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
|
|
184
|
+
const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
|
|
185
|
+
const result = defaultsDeep(defaultsFromCli, customOptions);
|
|
186
|
+
return {
|
|
187
|
+
...result, // Keep explicit empty strings/zeros; drop only undefined
|
|
188
|
+
vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Zod schemas for programmatic GetDotenv options.
|
|
194
|
+
*
|
|
195
|
+
* NOTE: These schemas are introduced without wiring to avoid behavior changes.
|
|
196
|
+
* Legacy paths continue to use existing types/logic. The new plugin host will
|
|
197
|
+
* use these schemas in strict mode; legacy paths will adopt them in warn mode
|
|
198
|
+
* later per the staged plan.
|
|
199
|
+
*/
|
|
200
|
+
// Minimal process env representation: string values or undefined to indicate "unset".
|
|
201
|
+
const processEnvSchema = z.record(z.string(), z.string().optional());
|
|
202
|
+
// RAW: all fields optional — undefined means "inherit" from lower layers.
|
|
203
|
+
const getDotenvOptionsSchemaRaw = z.object({
|
|
204
|
+
defaultEnv: z.string().optional(),
|
|
205
|
+
dotenvToken: z.string().optional(),
|
|
206
|
+
dynamicPath: z.string().optional(),
|
|
207
|
+
// Dynamic map is intentionally wide for now; refine once sources are normalized.
|
|
208
|
+
dynamic: z.record(z.string(), z.unknown()).optional(),
|
|
209
|
+
env: z.string().optional(),
|
|
210
|
+
excludeDynamic: z.boolean().optional(),
|
|
211
|
+
excludeEnv: z.boolean().optional(),
|
|
212
|
+
excludeGlobal: z.boolean().optional(),
|
|
213
|
+
excludePrivate: z.boolean().optional(),
|
|
214
|
+
excludePublic: z.boolean().optional(),
|
|
215
|
+
loadProcess: z.boolean().optional(),
|
|
216
|
+
log: z.boolean().optional(),
|
|
217
|
+
outputPath: z.string().optional(),
|
|
218
|
+
paths: z.array(z.string()).optional(),
|
|
219
|
+
privateToken: z.string().optional(),
|
|
220
|
+
vars: processEnvSchema.optional(),
|
|
221
|
+
// Host-only feature flag: guarded integration of config loader/overlay
|
|
222
|
+
useConfigLoader: z.boolean().optional(),
|
|
223
|
+
});
|
|
224
|
+
// RESOLVED: service-boundary contract (post-inheritance).
|
|
225
|
+
// For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
|
|
226
|
+
const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Zod schemas for configuration files discovered by the new loader. *
|
|
230
|
+
* Notes:
|
|
231
|
+
* - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
|
|
232
|
+
* - RESOLVED: normalized shapes (paths always string[]).
|
|
233
|
+
* - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
|
|
234
|
+
*/
|
|
235
|
+
// String-only env value map
|
|
236
|
+
const stringMap = z.record(z.string(), z.string());
|
|
237
|
+
const envStringMap = z.record(z.string(), stringMap);
|
|
238
|
+
// Allow string[] or single string for "paths" in RAW; normalize later.
|
|
239
|
+
const rawPathsSchema = z.union([z.array(z.string()), z.string()]).optional();
|
|
240
|
+
const getDotenvConfigSchemaRaw = z.object({
|
|
241
|
+
dotenvToken: z.string().optional(),
|
|
242
|
+
privateToken: z.string().optional(),
|
|
243
|
+
paths: rawPathsSchema,
|
|
244
|
+
loadProcess: z.boolean().optional(),
|
|
245
|
+
log: z.boolean().optional(),
|
|
246
|
+
shell: z.union([z.string(), z.boolean()]).optional(),
|
|
247
|
+
scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
|
|
248
|
+
vars: stringMap.optional(), // public, global
|
|
249
|
+
envVars: envStringMap.optional(), // public, per-env
|
|
250
|
+
// Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
|
|
251
|
+
dynamic: z.unknown().optional(),
|
|
252
|
+
// Per-plugin config bag; validated by plugins/host when used.
|
|
253
|
+
plugins: z.record(z.string(), z.unknown()).optional(),
|
|
254
|
+
});
|
|
255
|
+
// Normalize paths to string[]
|
|
256
|
+
const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
|
|
257
|
+
const getDotenvConfigSchemaResolved = getDotenvConfigSchemaRaw.transform((raw) => ({
|
|
258
|
+
...raw,
|
|
259
|
+
paths: normalizePaths(raw.paths),
|
|
260
|
+
}));
|
|
261
|
+
|
|
262
|
+
// Discovery candidates (first match wins per scope/privacy).
|
|
263
|
+
// Order preserves historical JSON/YAML precedence; JS/TS added afterwards.
|
|
264
|
+
const PUBLIC_FILENAMES = [
|
|
265
|
+
'getdotenv.config.json',
|
|
266
|
+
'getdotenv.config.yaml',
|
|
267
|
+
'getdotenv.config.yml',
|
|
268
|
+
'getdotenv.config.js',
|
|
269
|
+
'getdotenv.config.mjs',
|
|
270
|
+
'getdotenv.config.cjs',
|
|
271
|
+
'getdotenv.config.ts',
|
|
272
|
+
'getdotenv.config.mts',
|
|
273
|
+
'getdotenv.config.cts',
|
|
274
|
+
];
|
|
275
|
+
const LOCAL_FILENAMES = [
|
|
276
|
+
'getdotenv.config.local.json',
|
|
277
|
+
'getdotenv.config.local.yaml',
|
|
278
|
+
'getdotenv.config.local.yml',
|
|
279
|
+
'getdotenv.config.local.js',
|
|
280
|
+
'getdotenv.config.local.mjs',
|
|
281
|
+
'getdotenv.config.local.cjs',
|
|
282
|
+
'getdotenv.config.local.ts',
|
|
283
|
+
'getdotenv.config.local.mts',
|
|
284
|
+
'getdotenv.config.local.cts',
|
|
285
|
+
];
|
|
286
|
+
const isYaml = (p) => ['.yaml', '.yml'].includes(extname(p).toLowerCase());
|
|
287
|
+
const isJson = (p) => extname(p).toLowerCase() === '.json';
|
|
288
|
+
const isJsOrTs = (p) => ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(extname(p).toLowerCase());
|
|
289
|
+
// --- Internal JS/TS module loader helpers (default export) ---
|
|
290
|
+
const importDefault$1 = async (fileUrl) => {
|
|
291
|
+
const mod = (await import(fileUrl));
|
|
292
|
+
return mod.default;
|
|
293
|
+
};
|
|
294
|
+
const cacheName = (absPath, suffix) => {
|
|
295
|
+
// sanitized filename with suffix; recompile on mtime changes not tracked here (simplified)
|
|
296
|
+
const base = path.basename(absPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
297
|
+
return `${base}.${suffix}.mjs`;
|
|
298
|
+
};
|
|
299
|
+
const ensureDir = async (dir) => {
|
|
300
|
+
await fs.ensureDir(dir);
|
|
301
|
+
return dir;
|
|
302
|
+
};
|
|
303
|
+
const loadJsTsDefault = async (absPath) => {
|
|
304
|
+
const fileUrl = pathToFileURL(absPath).toString();
|
|
305
|
+
const ext = extname(absPath).toLowerCase();
|
|
306
|
+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
307
|
+
return importDefault$1(fileUrl);
|
|
308
|
+
}
|
|
309
|
+
// Try direct import first in case a TS loader is active.
|
|
310
|
+
try {
|
|
311
|
+
const val = await importDefault$1(fileUrl);
|
|
312
|
+
if (val)
|
|
313
|
+
return val;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
/* fallthrough */
|
|
317
|
+
}
|
|
318
|
+
// esbuild bundle to a temp ESM file
|
|
319
|
+
try {
|
|
320
|
+
const esbuild = (await import('esbuild'));
|
|
321
|
+
const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
|
|
322
|
+
const outfile = path.join(outDir, cacheName(absPath, 'bundle'));
|
|
323
|
+
await esbuild.build({
|
|
324
|
+
entryPoints: [absPath],
|
|
325
|
+
bundle: true,
|
|
326
|
+
platform: 'node',
|
|
327
|
+
format: 'esm',
|
|
328
|
+
target: 'node20',
|
|
329
|
+
outfile,
|
|
330
|
+
sourcemap: false,
|
|
331
|
+
logLevel: 'silent',
|
|
332
|
+
});
|
|
333
|
+
return await importDefault$1(pathToFileURL(outfile).toString());
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* fallthrough to TS transpile */
|
|
337
|
+
}
|
|
338
|
+
// typescript.transpileModule simple transpile (single-file)
|
|
339
|
+
try {
|
|
340
|
+
const ts = (await import('typescript'));
|
|
341
|
+
const src = await fs.readFile(absPath, 'utf-8');
|
|
342
|
+
const out = ts.transpileModule(src, {
|
|
343
|
+
compilerOptions: {
|
|
344
|
+
module: 'ESNext',
|
|
345
|
+
target: 'ES2022',
|
|
346
|
+
moduleResolution: 'NodeNext',
|
|
347
|
+
},
|
|
348
|
+
}).outputText;
|
|
349
|
+
const outDir = await ensureDir(path.resolve('.tsbuild', 'getdotenv-config'));
|
|
350
|
+
const outfile = path.join(outDir, cacheName(absPath, 'ts'));
|
|
351
|
+
await fs.writeFile(outfile, out, 'utf-8');
|
|
352
|
+
return await importDefault$1(pathToFileURL(outfile).toString());
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
throw new Error(`Unable to load JS/TS config: ${absPath}. Install 'esbuild' for robust bundling or ensure a TS loader.`);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Discover JSON/YAML config files in the packaged root and project root.
|
|
360
|
+
* Order: packaged public → project public → project local. */
|
|
361
|
+
const discoverConfigFiles = async (importMetaUrl) => {
|
|
362
|
+
const files = [];
|
|
363
|
+
// Packaged root via importMetaUrl (optional)
|
|
364
|
+
if (importMetaUrl) {
|
|
365
|
+
const fromUrl = fileURLToPath(importMetaUrl);
|
|
366
|
+
const packagedRoot = await packageDirectory({ cwd: fromUrl });
|
|
367
|
+
if (packagedRoot) {
|
|
368
|
+
for (const name of PUBLIC_FILENAMES) {
|
|
369
|
+
const p = join(packagedRoot, name);
|
|
370
|
+
if (await fs.pathExists(p)) {
|
|
371
|
+
files.push({ path: p, privacy: 'public', scope: 'packaged' });
|
|
372
|
+
break; // only one public file expected per scope
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// By policy, packaged .local is not expected; skip even if present.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Project root (from current working directory)
|
|
379
|
+
const projectRoot = await packageDirectory();
|
|
380
|
+
if (projectRoot) {
|
|
381
|
+
for (const name of PUBLIC_FILENAMES) {
|
|
382
|
+
const p = join(projectRoot, name);
|
|
383
|
+
if (await fs.pathExists(p)) {
|
|
384
|
+
files.push({ path: p, privacy: 'public', scope: 'project' });
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const name of LOCAL_FILENAMES) {
|
|
389
|
+
const p = join(projectRoot, name);
|
|
390
|
+
if (await fs.pathExists(p)) {
|
|
391
|
+
files.push({ path: p, privacy: 'local', scope: 'project' });
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return files;
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* Load a single config file (JSON/YAML). JS/TS is not supported in this step.
|
|
400
|
+
* Validates with Zod RAW schema, then normalizes to RESOLVED.
|
|
401
|
+
*
|
|
402
|
+
* For JSON/YAML: if a "dynamic" property is present, throws with guidance.
|
|
403
|
+
* For JS/TS: default export is loaded; "dynamic" is allowed.
|
|
404
|
+
*/
|
|
405
|
+
const loadConfigFile = async (filePath) => {
|
|
406
|
+
let raw = {};
|
|
407
|
+
try {
|
|
408
|
+
const abs = path.resolve(filePath);
|
|
409
|
+
if (isJsOrTs(abs)) {
|
|
410
|
+
// JS/TS support: load default export via robust pipeline.
|
|
411
|
+
const mod = await loadJsTsDefault(abs);
|
|
412
|
+
raw = mod ?? {};
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const txt = await fs.readFile(abs, 'utf-8');
|
|
416
|
+
raw = isJson(abs) ? JSON.parse(txt) : isYaml(abs) ? YAML.parse(txt) : {};
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
throw new Error(`Failed to read/parse config: ${filePath}. ${String(err)}`);
|
|
421
|
+
}
|
|
422
|
+
// Validate RAW
|
|
423
|
+
const parsed = getDotenvConfigSchemaRaw.safeParse(raw);
|
|
424
|
+
if (!parsed.success) {
|
|
425
|
+
const msgs = parsed.error.issues
|
|
426
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
427
|
+
.join('\n');
|
|
428
|
+
throw new Error(`Invalid config ${filePath}:\n${msgs}`);
|
|
429
|
+
}
|
|
430
|
+
// Disallow dynamic in JSON/YAML; allow in JS/TS
|
|
431
|
+
if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
|
|
432
|
+
throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
|
|
433
|
+
}
|
|
434
|
+
return getDotenvConfigSchemaResolved.parse(parsed.data);
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* Discover and load configs into resolved shapes, ordered by scope/privacy.
|
|
438
|
+
* JSON/YAML/JS/TS supported; first match per scope/privacy applies.
|
|
439
|
+
*/
|
|
440
|
+
const resolveGetDotenvConfigSources = async (importMetaUrl) => {
|
|
441
|
+
const discovered = await discoverConfigFiles(importMetaUrl);
|
|
442
|
+
const result = {};
|
|
443
|
+
for (const f of discovered) {
|
|
444
|
+
const cfg = await loadConfigFile(f.path);
|
|
445
|
+
if (f.scope === 'packaged') {
|
|
446
|
+
// packaged public only
|
|
447
|
+
result.packaged = cfg;
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
result.project ??= {};
|
|
451
|
+
if (f.privacy === 'public')
|
|
452
|
+
result.project.public = cfg;
|
|
453
|
+
else
|
|
454
|
+
result.project.local = cfg;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return result;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Dotenv expansion utilities.
|
|
462
|
+
*
|
|
463
|
+
* This module implements recursive expansion of environment-variable
|
|
464
|
+
* references in strings and records. It supports both whitespace and
|
|
465
|
+
* bracket syntaxes with optional defaults:
|
|
466
|
+
*
|
|
467
|
+
* - Whitespace: `$VAR[:default]`
|
|
468
|
+
* - Bracketed: `${VAR[:default]}`
|
|
469
|
+
*
|
|
470
|
+
* Escaped dollar signs (`\$`) are preserved.
|
|
471
|
+
* Unknown variables resolve to empty string unless a default is provided.
|
|
472
|
+
*/
|
|
473
|
+
/**
|
|
474
|
+
* Like String.prototype.search but returns the last index.
|
|
475
|
+
* @internal
|
|
476
|
+
*/
|
|
477
|
+
const searchLast = (str, rgx) => {
|
|
478
|
+
const matches = Array.from(str.matchAll(rgx));
|
|
479
|
+
return matches.length > 0 ? (matches.slice(-1)[0]?.index ?? -1) : -1;
|
|
480
|
+
};
|
|
481
|
+
const replaceMatch = (value, match, ref) => {
|
|
482
|
+
/**
|
|
483
|
+
* @internal
|
|
484
|
+
*/
|
|
485
|
+
const group = match[0];
|
|
486
|
+
const key = match[1];
|
|
487
|
+
const defaultValue = match[2];
|
|
488
|
+
if (!key)
|
|
489
|
+
return value;
|
|
490
|
+
const replacement = value.replace(group, ref[key] ?? defaultValue ?? '');
|
|
491
|
+
return interpolate(replacement, ref);
|
|
492
|
+
};
|
|
493
|
+
const interpolate = (value = '', ref = {}) => {
|
|
494
|
+
/**
|
|
495
|
+
* @internal
|
|
496
|
+
*/
|
|
497
|
+
// if value is falsy, return it as is
|
|
498
|
+
if (!value)
|
|
499
|
+
return value;
|
|
500
|
+
// get position of last unescaped dollar sign
|
|
501
|
+
const lastUnescapedDollarSignIndex = searchLast(value, /(?!(?<=\\))\$/g);
|
|
502
|
+
// return value if none found
|
|
503
|
+
if (lastUnescapedDollarSignIndex === -1)
|
|
504
|
+
return value;
|
|
505
|
+
// evaluate the value tail
|
|
506
|
+
const tail = value.slice(lastUnescapedDollarSignIndex);
|
|
507
|
+
// find whitespace pattern: $KEY:DEFAULT
|
|
508
|
+
const whitespacePattern = /^\$([\w]+)(?::([^\s]*))?/;
|
|
509
|
+
const whitespaceMatch = whitespacePattern.exec(tail);
|
|
510
|
+
if (whitespaceMatch != null)
|
|
511
|
+
return replaceMatch(value, whitespaceMatch, ref);
|
|
512
|
+
else {
|
|
513
|
+
// find bracket pattern: ${KEY:DEFAULT}
|
|
514
|
+
const bracketPattern = /^\${([\w]+)(?::([^}]*))?}/;
|
|
515
|
+
const bracketMatch = bracketPattern.exec(tail);
|
|
516
|
+
if (bracketMatch != null)
|
|
517
|
+
return replaceMatch(value, bracketMatch, ref);
|
|
518
|
+
}
|
|
519
|
+
return value;
|
|
520
|
+
};
|
|
521
|
+
/**
|
|
522
|
+
* Recursively expands environment variables in a string. Variables may be
|
|
523
|
+
* presented with optional default as `$VAR[:default]` or `${VAR[:default]}`.
|
|
524
|
+
* Unknown variables will expand to an empty string.
|
|
525
|
+
*
|
|
526
|
+
* @param value - The string to expand.
|
|
527
|
+
* @param ref - The reference object to use for variable expansion.
|
|
528
|
+
* @returns The expanded string.
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* ```ts
|
|
532
|
+
* process.env.FOO = 'bar';
|
|
533
|
+
* dotenvExpand('Hello $FOO'); // "Hello bar"
|
|
534
|
+
* dotenvExpand('Hello $BAZ:world'); // "Hello world"
|
|
535
|
+
* ```
|
|
536
|
+
*
|
|
537
|
+
* @remarks
|
|
538
|
+
* The expansion is recursive. If a referenced variable itself contains
|
|
539
|
+
* references, those will also be expanded until a stable value is reached.
|
|
540
|
+
* Escaped references (e.g. `\$FOO`) are preserved as literals.
|
|
541
|
+
*/
|
|
542
|
+
const dotenvExpand = (value, ref = process.env) => {
|
|
543
|
+
const result = interpolate(value, ref);
|
|
544
|
+
return result ? result.replace(/\\\$/g, '$') : undefined;
|
|
545
|
+
};
|
|
546
|
+
/**
|
|
547
|
+
* Recursively expands environment variables in the values of a JSON object.
|
|
548
|
+
* Variables may be presented with optional default as `$VAR[:default]` or
|
|
549
|
+
* `${VAR[:default]}`. Unknown variables will expand to an empty string.
|
|
550
|
+
*
|
|
551
|
+
* @param values - The values object to expand.
|
|
552
|
+
* @param options - Expansion options.
|
|
553
|
+
* @returns The value object with expanded string values.
|
|
554
|
+
*
|
|
555
|
+
* @example
|
|
556
|
+
* ```ts
|
|
557
|
+
* process.env.FOO = 'bar';
|
|
558
|
+
* dotenvExpandAll({ A: '$FOO', B: 'x${FOO}y' });
|
|
559
|
+
* // => { A: "bar", B: "xbary" }
|
|
560
|
+
* ```
|
|
561
|
+
*
|
|
562
|
+
* @remarks
|
|
563
|
+
* Options:
|
|
564
|
+
* - ref: The reference object to use for expansion (defaults to process.env).
|
|
565
|
+
* - progressive: Whether to progressively add expanded values to the set of
|
|
566
|
+
* reference keys.
|
|
567
|
+
*
|
|
568
|
+
* When `progressive` is true, each expanded key becomes available for
|
|
569
|
+
* subsequent expansions in the same object (left-to-right by object key order).
|
|
570
|
+
*/
|
|
571
|
+
const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduce((acc, key) => {
|
|
572
|
+
const { ref = process.env, progressive = false } = options;
|
|
573
|
+
acc[key] = dotenvExpand(values[key], {
|
|
574
|
+
...ref,
|
|
575
|
+
...(progressive ? acc : {}),
|
|
576
|
+
});
|
|
577
|
+
return acc;
|
|
578
|
+
}, {});
|
|
579
|
+
|
|
580
|
+
const applyKv = (current, kv) => {
|
|
581
|
+
if (!kv || Object.keys(kv).length === 0)
|
|
582
|
+
return current;
|
|
583
|
+
const expanded = dotenvExpandAll(kv, { ref: current, progressive: true });
|
|
584
|
+
return { ...current, ...expanded };
|
|
585
|
+
};
|
|
586
|
+
const applyConfigSlice = (current, cfg, env) => {
|
|
587
|
+
if (!cfg)
|
|
588
|
+
return current;
|
|
589
|
+
// kind axis: global then env (env overrides global)
|
|
590
|
+
const afterGlobal = applyKv(current, cfg.vars);
|
|
591
|
+
const envKv = env && cfg.envVars ? cfg.envVars[env] : undefined;
|
|
592
|
+
return applyKv(afterGlobal, envKv);
|
|
593
|
+
};
|
|
594
|
+
/**
|
|
595
|
+
* Overlay config-provided values onto a base ProcessEnv using precedence axes:
|
|
596
|
+
* - kind: env \> global
|
|
597
|
+
* - privacy: local \> public
|
|
598
|
+
* - source: project \> packaged \> base
|
|
599
|
+
*
|
|
600
|
+
* Programmatic explicit vars (if provided) override all config slices.
|
|
601
|
+
* Progressive expansion is applied within each slice.
|
|
602
|
+
*/
|
|
603
|
+
const overlayEnv = ({ base, env, configs, programmaticVars, }) => {
|
|
604
|
+
let current = { ...base };
|
|
605
|
+
// Source: packaged (public -> local)
|
|
606
|
+
current = applyConfigSlice(current, configs.packaged, env);
|
|
607
|
+
// Packaged "local" is not expected by policy; if present, honor it.
|
|
608
|
+
// We do not have a separate object for packaged.local in sources, keep as-is.
|
|
609
|
+
// Source: project (public -> local)
|
|
610
|
+
current = applyConfigSlice(current, configs.project?.public, env);
|
|
611
|
+
current = applyConfigSlice(current, configs.project?.local, env);
|
|
612
|
+
// Programmatic explicit vars (top of static tier)
|
|
613
|
+
if (programmaticVars) {
|
|
614
|
+
const toApply = Object.fromEntries(Object.entries(programmaticVars).filter(([_k, v]) => typeof v === 'string'));
|
|
615
|
+
current = applyKv(current, toApply);
|
|
616
|
+
}
|
|
617
|
+
return current;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Asynchronously read a dotenv file & parse it into an object.
|
|
622
|
+
*
|
|
623
|
+
* @param path - Path to dotenv file.
|
|
624
|
+
* @returns The parsed dotenv object.
|
|
625
|
+
*/
|
|
626
|
+
const readDotenv = async (path) => {
|
|
627
|
+
try {
|
|
628
|
+
return (await fs.exists(path)) ? parse(await fs.readFile(path)) : {};
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return {};
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const importDefault = async (fileUrl) => {
|
|
636
|
+
const mod = (await import(fileUrl));
|
|
637
|
+
return mod.default;
|
|
638
|
+
};
|
|
639
|
+
const cacheHash = (absPath, mtimeMs) => createHash('sha1')
|
|
640
|
+
.update(absPath)
|
|
641
|
+
.update(String(mtimeMs))
|
|
642
|
+
.digest('hex')
|
|
643
|
+
.slice(0, 12);
|
|
644
|
+
/**
|
|
645
|
+
* Remove older compiled cache files for a given source base name, keeping
|
|
646
|
+
* at most `keep` most-recent files. Errors are ignored by design.
|
|
647
|
+
*/
|
|
648
|
+
const cleanupOldCacheFiles = async (cacheDir, baseName, keep = Math.max(1, Number.parseInt(process.env.GETDOTENV_CACHE_KEEP ?? '2'))) => {
|
|
649
|
+
try {
|
|
650
|
+
const entries = await fs.readdir(cacheDir);
|
|
651
|
+
const mine = entries
|
|
652
|
+
.filter((f) => f.startsWith(`${baseName}.`) && f.endsWith('.mjs'))
|
|
653
|
+
.map((f) => path.join(cacheDir, f));
|
|
654
|
+
if (mine.length <= keep)
|
|
655
|
+
return;
|
|
656
|
+
const stats = await Promise.all(mine.map(async (p) => ({ p, mtimeMs: (await fs.stat(p)).mtimeMs })));
|
|
657
|
+
stats.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
658
|
+
const toDelete = stats.slice(keep).map((s) => s.p);
|
|
659
|
+
await Promise.all(toDelete.map(async (p) => {
|
|
660
|
+
try {
|
|
661
|
+
await fs.remove(p);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// best-effort cleanup
|
|
665
|
+
}
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
// best-effort cleanup
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* Load a module default export from a JS/TS file with robust fallbacks:
|
|
674
|
+
* - .js/.mjs/.cjs: direct import * - .ts/.mts/.cts/.tsx:
|
|
675
|
+
* 1) try direct import (if a TS loader is active),
|
|
676
|
+
* 2) esbuild bundle to a temp ESM file,
|
|
677
|
+
* 3) typescript.transpileModule fallback for simple modules.
|
|
678
|
+
*
|
|
679
|
+
* @param absPath - absolute path to source file
|
|
680
|
+
* @param cacheDirName - cache subfolder under .tsbuild
|
|
681
|
+
*/
|
|
682
|
+
const loadModuleDefault = async (absPath, cacheDirName) => {
|
|
683
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
684
|
+
const fileUrl = url.pathToFileURL(absPath).toString();
|
|
685
|
+
if (!['.ts', '.mts', '.cts', '.tsx'].includes(ext)) {
|
|
686
|
+
return importDefault(fileUrl);
|
|
687
|
+
}
|
|
688
|
+
// Try direct import first (TS loader active)
|
|
689
|
+
try {
|
|
690
|
+
const dyn = await importDefault(fileUrl);
|
|
691
|
+
if (dyn)
|
|
692
|
+
return dyn;
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
/* fall through */
|
|
696
|
+
}
|
|
697
|
+
const stat = await fs.stat(absPath);
|
|
698
|
+
const hash = cacheHash(absPath, stat.mtimeMs);
|
|
699
|
+
const cacheDir = path.resolve('.tsbuild', cacheDirName);
|
|
700
|
+
await fs.ensureDir(cacheDir);
|
|
701
|
+
const cacheFile = path.join(cacheDir, `${path.basename(absPath)}.${hash}.mjs`);
|
|
702
|
+
// Try esbuild
|
|
703
|
+
try {
|
|
704
|
+
const esbuild = (await import('esbuild'));
|
|
705
|
+
await esbuild.build({
|
|
706
|
+
entryPoints: [absPath],
|
|
707
|
+
bundle: true,
|
|
708
|
+
platform: 'node',
|
|
709
|
+
format: 'esm',
|
|
710
|
+
target: 'node20',
|
|
711
|
+
outfile: cacheFile,
|
|
712
|
+
sourcemap: false,
|
|
713
|
+
logLevel: 'silent',
|
|
714
|
+
});
|
|
715
|
+
const result = await importDefault(url.pathToFileURL(cacheFile).toString());
|
|
716
|
+
// Best-effort: trim older cache files for this source.
|
|
717
|
+
await cleanupOldCacheFiles(cacheDir, path.basename(absPath));
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
/* fall through to TS transpile */
|
|
722
|
+
}
|
|
723
|
+
// TypeScript transpile fallback
|
|
724
|
+
try {
|
|
725
|
+
const ts = (await import('typescript'));
|
|
726
|
+
const code = await fs.readFile(absPath, 'utf-8');
|
|
727
|
+
const out = ts.transpileModule(code, {
|
|
728
|
+
compilerOptions: {
|
|
729
|
+
module: 'ESNext',
|
|
730
|
+
target: 'ES2022',
|
|
731
|
+
moduleResolution: 'NodeNext',
|
|
732
|
+
},
|
|
733
|
+
}).outputText;
|
|
734
|
+
await fs.writeFile(cacheFile, out, 'utf-8');
|
|
735
|
+
const result = await importDefault(url.pathToFileURL(cacheFile).toString());
|
|
736
|
+
// Best-effort: trim older cache files for this source.
|
|
737
|
+
await cleanupOldCacheFiles(cacheDir, path.basename(absPath));
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
// Caller decides final error wording; rethrow for upstream mapping.
|
|
742
|
+
throw new Error(`Unable to load JS/TS module: ${absPath}. Install 'esbuild' or ensure a TS loader.`);
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Asynchronously process dotenv files of the form `.env[.<ENV>][.<PRIVATE_TOKEN>]`
|
|
748
|
+
*
|
|
749
|
+
* @param options - `GetDotenvOptions` object
|
|
750
|
+
* @returns The combined parsed dotenv object.
|
|
751
|
+
* * @example Load from the project root with default tokens
|
|
752
|
+
* ```ts
|
|
753
|
+
* const vars = await getDotenv();
|
|
754
|
+
* console.log(vars.MY_SETTING);
|
|
755
|
+
* ```
|
|
756
|
+
*
|
|
757
|
+
* @example Load from multiple paths and a specific environment
|
|
758
|
+
* ```ts
|
|
759
|
+
* const vars = await getDotenv({
|
|
760
|
+
* env: 'dev',
|
|
761
|
+
* dotenvToken: '.testenv',
|
|
762
|
+
* privateToken: 'secret',
|
|
763
|
+
* paths: ['./', './packages/app'],
|
|
764
|
+
* });
|
|
765
|
+
* ```
|
|
766
|
+
*
|
|
767
|
+
* @example Use dynamic variables
|
|
768
|
+
* ```ts
|
|
769
|
+
* // .env.js default-exports: { DYNAMIC: ({ PREV }) => `${PREV}-suffix` }
|
|
770
|
+
* const vars = await getDotenv({ dynamicPath: '.env.js' });
|
|
771
|
+
* ```
|
|
772
|
+
*
|
|
773
|
+
* @remarks
|
|
774
|
+
* - When {@link GetDotenvOptions.loadProcess} is true, the resulting variables are merged
|
|
775
|
+
* into `process.env` as a side effect.
|
|
776
|
+
* - When {@link GetDotenvOptions.outputPath} is provided, a consolidated dotenv file is written.
|
|
777
|
+
* The path is resolved after expansion, so it may reference previously loaded vars.
|
|
778
|
+
*
|
|
779
|
+
* @throws Error when a dynamic module is present but cannot be imported.
|
|
780
|
+
* @throws Error when an output path was requested but could not be resolved.
|
|
781
|
+
*/
|
|
782
|
+
const getDotenv = async (options = {}) => {
|
|
783
|
+
// Apply defaults.
|
|
784
|
+
const { defaultEnv, dotenvToken = '.env', dynamicPath, env, excludeDynamic = false, excludeEnv = false, excludeGlobal = false, excludePrivate = false, excludePublic = false, loadProcess = false, log = false, logger = console, outputPath, paths = [], privateToken = 'local', vars = {}, } = await resolveGetDotenvOptions(options);
|
|
785
|
+
// Read .env files.
|
|
786
|
+
const loaded = paths.length
|
|
787
|
+
? await paths.reduce(async (e, p) => {
|
|
788
|
+
const publicGlobal = excludePublic || excludeGlobal
|
|
789
|
+
? Promise.resolve({})
|
|
790
|
+
: readDotenv(path.resolve(p, dotenvToken));
|
|
791
|
+
const publicEnv = excludePublic || excludeEnv || (!env && !defaultEnv)
|
|
792
|
+
? Promise.resolve({})
|
|
793
|
+
: readDotenv(path.resolve(p, `${dotenvToken}.${env ?? defaultEnv ?? ''}`));
|
|
794
|
+
const privateGlobal = excludePrivate || excludeGlobal
|
|
795
|
+
? Promise.resolve({})
|
|
796
|
+
: readDotenv(path.resolve(p, `${dotenvToken}.${privateToken}`));
|
|
797
|
+
const privateEnv = excludePrivate || excludeEnv || (!env && !defaultEnv)
|
|
798
|
+
? Promise.resolve({})
|
|
799
|
+
: readDotenv(path.resolve(p, `${dotenvToken}.${env ?? defaultEnv ?? ''}.${privateToken}`));
|
|
800
|
+
const [eResolved, publicGlobalResolved, publicEnvResolved, privateGlobalResolved, privateEnvResolved,] = await Promise.all([
|
|
801
|
+
e,
|
|
802
|
+
publicGlobal,
|
|
803
|
+
publicEnv,
|
|
804
|
+
privateGlobal,
|
|
805
|
+
privateEnv,
|
|
806
|
+
]);
|
|
807
|
+
return {
|
|
808
|
+
...eResolved,
|
|
809
|
+
...publicGlobalResolved,
|
|
810
|
+
...publicEnvResolved,
|
|
811
|
+
...privateGlobalResolved,
|
|
812
|
+
...privateEnvResolved,
|
|
813
|
+
};
|
|
814
|
+
}, Promise.resolve({}))
|
|
815
|
+
: {};
|
|
816
|
+
const outputKey = nanoid();
|
|
817
|
+
const dotenv = dotenvExpandAll({
|
|
818
|
+
...loaded,
|
|
819
|
+
...vars,
|
|
820
|
+
...(outputPath ? { [outputKey]: outputPath } : {}),
|
|
821
|
+
}, { progressive: true });
|
|
822
|
+
// Process dynamic variables. Programmatic option takes precedence over path.
|
|
823
|
+
if (!excludeDynamic) {
|
|
824
|
+
let dynamic = undefined;
|
|
825
|
+
if (options.dynamic && Object.keys(options.dynamic).length > 0) {
|
|
826
|
+
dynamic = options.dynamic;
|
|
827
|
+
}
|
|
828
|
+
else if (dynamicPath) {
|
|
829
|
+
const absDynamicPath = path.resolve(dynamicPath);
|
|
830
|
+
if (await fs.exists(absDynamicPath)) {
|
|
831
|
+
try {
|
|
832
|
+
dynamic = await loadModuleDefault(absDynamicPath, 'getdotenv-dynamic');
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
// Preserve legacy error text for compatibility with tests/docs.
|
|
836
|
+
throw new Error(`Unable to load dynamic TypeScript file: ${absDynamicPath}. ` +
|
|
837
|
+
`Install 'esbuild' (devDependency) to enable TypeScript dynamic modules.`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (dynamic) {
|
|
842
|
+
try {
|
|
843
|
+
for (const key in dynamic)
|
|
844
|
+
Object.assign(dotenv, {
|
|
845
|
+
[key]: typeof dynamic[key] === 'function'
|
|
846
|
+
? dynamic[key](dotenv, env ?? defaultEnv)
|
|
847
|
+
: dynamic[key],
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
throw new Error(`Unable to evaluate dynamic variables.`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// Write output file.
|
|
856
|
+
let resultDotenv = dotenv;
|
|
857
|
+
if (outputPath) {
|
|
858
|
+
const outputPathResolved = dotenv[outputKey];
|
|
859
|
+
if (!outputPathResolved)
|
|
860
|
+
throw new Error('Output path not found.');
|
|
861
|
+
const { [outputKey]: _omitted, ...dotenvForOutput } = dotenv;
|
|
862
|
+
await fs.writeFile(outputPathResolved, Object.keys(dotenvForOutput).reduce((contents, key) => {
|
|
863
|
+
const value = dotenvForOutput[key] ?? '';
|
|
864
|
+
return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
|
|
865
|
+
}, ''), { encoding: 'utf-8' });
|
|
866
|
+
resultDotenv = dotenvForOutput;
|
|
867
|
+
}
|
|
868
|
+
// Log result.
|
|
869
|
+
if (log)
|
|
870
|
+
logger.log(resultDotenv);
|
|
871
|
+
// Load process.env.
|
|
872
|
+
if (loadProcess)
|
|
873
|
+
Object.assign(process.env, resultDotenv);
|
|
874
|
+
return resultDotenv;
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Compute the dotenv context for the host (uses the config loader/overlay path).
|
|
879
|
+
* - Resolves and validates options strictly (host-only).
|
|
880
|
+
* - Applies file cascade, overlays, dynamics, and optional effects.
|
|
881
|
+
* - Merges and validates per-plugin config slices (when provided).
|
|
882
|
+
*
|
|
883
|
+
* @param customOptions - Partial options from the current invocation.
|
|
884
|
+
* @param plugins - Installed plugins (for config validation).
|
|
885
|
+
* @param hostMetaUrl - import.meta.url of the host module (for packaged root discovery). */
|
|
886
|
+
const computeContext = async (customOptions, plugins, hostMetaUrl) => {
|
|
887
|
+
const optionsResolved = await resolveGetDotenvOptions(customOptions);
|
|
888
|
+
const validated = getDotenvOptionsSchemaResolved.parse(optionsResolved);
|
|
889
|
+
// Always-on loader path
|
|
890
|
+
// 1) Base from files only (no dynamic, no programmatic vars)
|
|
891
|
+
const base = await getDotenv({
|
|
892
|
+
...validated,
|
|
893
|
+
// Build a pure base without side effects or logging.
|
|
894
|
+
excludeDynamic: true,
|
|
895
|
+
vars: {},
|
|
896
|
+
log: false,
|
|
897
|
+
loadProcess: false,
|
|
898
|
+
outputPath: undefined,
|
|
899
|
+
});
|
|
900
|
+
// 2) Discover config sources and overlay
|
|
901
|
+
const sources = await resolveGetDotenvConfigSources(hostMetaUrl);
|
|
902
|
+
const dotenvOverlaid = overlayEnv({
|
|
903
|
+
base,
|
|
904
|
+
env: validated.env ?? validated.defaultEnv,
|
|
905
|
+
configs: sources,
|
|
906
|
+
...(validated.vars ? { programmaticVars: validated.vars } : {}),
|
|
907
|
+
});
|
|
908
|
+
// Helper to apply a dynamic map progressively.
|
|
909
|
+
const applyDynamic = (target, dynamic, env) => {
|
|
910
|
+
if (!dynamic)
|
|
911
|
+
return;
|
|
912
|
+
for (const key of Object.keys(dynamic)) {
|
|
913
|
+
const value = typeof dynamic[key] === 'function'
|
|
914
|
+
? dynamic[key](target, env)
|
|
915
|
+
: dynamic[key];
|
|
916
|
+
Object.assign(target, { [key]: value });
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
// 3) Apply dynamics in order
|
|
920
|
+
const dotenv = { ...dotenvOverlaid };
|
|
921
|
+
applyDynamic(dotenv, validated.dynamic, validated.env ?? validated.defaultEnv);
|
|
922
|
+
applyDynamic(dotenv, (sources.packaged?.dynamic ?? undefined), validated.env ?? validated.defaultEnv);
|
|
923
|
+
applyDynamic(dotenv, (sources.project?.public?.dynamic ?? undefined), validated.env ?? validated.defaultEnv);
|
|
924
|
+
applyDynamic(dotenv, (sources.project?.local?.dynamic ?? undefined), validated.env ?? validated.defaultEnv);
|
|
925
|
+
// file dynamicPath (lowest)
|
|
926
|
+
if (validated.dynamicPath) {
|
|
927
|
+
const absDynamicPath = path.resolve(validated.dynamicPath);
|
|
928
|
+
try {
|
|
929
|
+
const dyn = await loadModuleDefault(absDynamicPath, 'getdotenv-dynamic-host');
|
|
930
|
+
applyDynamic(dotenv, dyn, validated.env ?? validated.defaultEnv);
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
throw new Error(`Unable to load dynamic from ${validated.dynamicPath}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// 4) Output/log/process merge
|
|
937
|
+
if (validated.outputPath) {
|
|
938
|
+
await fs.writeFile(validated.outputPath, Object.keys(dotenv).reduce((contents, key) => {
|
|
939
|
+
const value = dotenv[key] ?? '';
|
|
940
|
+
return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
|
|
941
|
+
}, ''), { encoding: 'utf-8' });
|
|
942
|
+
}
|
|
943
|
+
const logger = validated.logger ?? console;
|
|
944
|
+
if (validated.log)
|
|
945
|
+
logger.log(dotenv);
|
|
946
|
+
if (validated.loadProcess)
|
|
947
|
+
Object.assign(process.env, dotenv);
|
|
948
|
+
// 5) Merge and validate per-plugin config (packaged < project.public < project.local)
|
|
949
|
+
const packagedPlugins = (sources.packaged &&
|
|
950
|
+
sources.packaged.plugins) ??
|
|
951
|
+
{};
|
|
952
|
+
const publicPlugins = (sources.project?.public &&
|
|
953
|
+
sources.project.public.plugins) ??
|
|
954
|
+
{};
|
|
955
|
+
const localPlugins = (sources.project?.local &&
|
|
956
|
+
sources.project.local.plugins) ??
|
|
957
|
+
{};
|
|
958
|
+
const mergedPluginConfigs = defaultsDeep({}, packagedPlugins, publicPlugins, localPlugins);
|
|
959
|
+
for (const p of plugins) {
|
|
960
|
+
if (!p.id || !p.configSchema)
|
|
961
|
+
continue;
|
|
962
|
+
const slice = mergedPluginConfigs[p.id];
|
|
963
|
+
if (slice === undefined)
|
|
964
|
+
continue;
|
|
965
|
+
const parsed = p.configSchema.safeParse(slice);
|
|
966
|
+
if (!parsed.success) {
|
|
967
|
+
const msgs = parsed.error.issues
|
|
968
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
969
|
+
.join('\n');
|
|
970
|
+
throw new Error(`Invalid config for plugin '${p.id}':\n${msgs}`);
|
|
971
|
+
}
|
|
972
|
+
mergedPluginConfigs[p.id] = parsed.data;
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
optionsResolved: validated,
|
|
976
|
+
dotenv: dotenv,
|
|
977
|
+
plugins: {},
|
|
978
|
+
pluginConfigs: mergedPluginConfigs,
|
|
979
|
+
};
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const HOST_META_URL = import.meta.url;
|
|
983
|
+
const CTX_SYMBOL = Symbol('GetDotenvCli.ctx');
|
|
984
|
+
/**
|
|
985
|
+
* Plugin-first CLI host for get-dotenv. Extends Commander.Command.
|
|
986
|
+
*
|
|
987
|
+
* Responsibilities:
|
|
988
|
+
* - Resolve options strictly and compute dotenv context (resolveAndLoad).
|
|
989
|
+
* - Expose a stable accessor for the current context (getCtx).
|
|
990
|
+
* - Provide a namespacing helper (ns).
|
|
991
|
+
* - Support composable plugins with parent → children install and afterResolve.
|
|
992
|
+
*
|
|
993
|
+
* NOTE: This host is additive and does not alter the legacy CLI.
|
|
994
|
+
*/
|
|
995
|
+
class GetDotenvCli extends Command {
|
|
996
|
+
/** Registered top-level plugins (composition happens via .use()) */
|
|
997
|
+
_plugins = [];
|
|
998
|
+
/** One-time installation guard */
|
|
999
|
+
_installed = false;
|
|
1000
|
+
constructor(alias = 'getdotenv') {
|
|
1001
|
+
super(alias);
|
|
1002
|
+
// Ensure subcommands that use passThroughOptions can be attached safely.
|
|
1003
|
+
// Commander requires parent commands to enable positional options when a
|
|
1004
|
+
// child uses passThroughOptions.
|
|
1005
|
+
this.enablePositionalOptions();
|
|
1006
|
+
// Skeleton preSubcommand hook: produce a context if absent, without
|
|
1007
|
+
// mutating process.env. The passOptions hook (when installed) will
|
|
1008
|
+
// compute the final context using merged CLI options; keeping
|
|
1009
|
+
// loadProcess=false here avoids leaking dotenv values into the parent
|
|
1010
|
+
// process env before subcommands execute.
|
|
1011
|
+
this.hook('preSubcommand', async () => {
|
|
1012
|
+
if (this.getCtx())
|
|
1013
|
+
return;
|
|
1014
|
+
await this.resolveAndLoad({ loadProcess: false });
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Resolve options (strict) and compute dotenv context. * Stores the context on the instance under a symbol.
|
|
1019
|
+
*/
|
|
1020
|
+
async resolveAndLoad(customOptions = {}) {
|
|
1021
|
+
// Resolve defaults, then validate strictly under the new host.
|
|
1022
|
+
const optionsResolved = await resolveGetDotenvOptions(customOptions);
|
|
1023
|
+
getDotenvOptionsSchemaResolved.parse(optionsResolved);
|
|
1024
|
+
// Delegate the heavy lifting to the shared helper (guarded path supported).
|
|
1025
|
+
const ctx = await computeContext(optionsResolved, this._plugins, HOST_META_URL);
|
|
1026
|
+
// Persist context on the instance for later access.
|
|
1027
|
+
this[CTX_SYMBOL] =
|
|
1028
|
+
ctx;
|
|
1029
|
+
// Ensure plugins are installed exactly once, then run afterResolve.
|
|
1030
|
+
await this.install();
|
|
1031
|
+
await this._runAfterResolve(ctx);
|
|
1032
|
+
return ctx;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Retrieve the current invocation context (if any).
|
|
1036
|
+
*/
|
|
1037
|
+
getCtx() {
|
|
1038
|
+
return this[CTX_SYMBOL];
|
|
1039
|
+
}
|
|
1040
|
+
/** * Convenience helper to create a namespaced subcommand.
|
|
1041
|
+
*/
|
|
1042
|
+
ns(name) {
|
|
1043
|
+
return this.command(name);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Register a plugin for installation (parent level).
|
|
1047
|
+
* Installation occurs on first resolveAndLoad() (or explicit install()).
|
|
1048
|
+
*/
|
|
1049
|
+
use(plugin) {
|
|
1050
|
+
this._plugins.push(plugin);
|
|
1051
|
+
// Immediately run setup so subcommands exist before parsing.
|
|
1052
|
+
const setupOne = (p) => {
|
|
1053
|
+
p.setup(this);
|
|
1054
|
+
for (const child of p.children)
|
|
1055
|
+
setupOne(child);
|
|
1056
|
+
};
|
|
1057
|
+
setupOne(plugin);
|
|
1058
|
+
return this;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Install all registered plugins in parent → children (pre-order).
|
|
1062
|
+
* Runs only once per CLI instance.
|
|
1063
|
+
*/
|
|
1064
|
+
async install() {
|
|
1065
|
+
// Setup is performed immediately in use(); here we only guard for afterResolve.
|
|
1066
|
+
this._installed = true;
|
|
1067
|
+
// Satisfy require-await without altering behavior.
|
|
1068
|
+
await Promise.resolve();
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Run afterResolve hooks for all plugins (parent → children).
|
|
1072
|
+
*/
|
|
1073
|
+
async _runAfterResolve(ctx) {
|
|
1074
|
+
const run = async (p) => {
|
|
1075
|
+
if (p.afterResolve)
|
|
1076
|
+
await p.afterResolve(this, ctx);
|
|
1077
|
+
for (const child of p.children)
|
|
1078
|
+
await run(child);
|
|
1079
|
+
};
|
|
1080
|
+
for (const p of this._plugins)
|
|
1081
|
+
await run(p);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export { GetDotenvCli, definePlugin };
|