@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/getdotenv.cli.mjs
CHANGED
|
@@ -1,14 +1,445 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, Option } from 'commander';
|
|
3
|
-
import { execaCommand } from 'execa';
|
|
4
|
-
import { globby } from 'globby';
|
|
5
|
-
import { packageDirectory } from 'package-directory';
|
|
6
|
-
import path, { join } from 'path';
|
|
7
3
|
import fs from 'fs-extra';
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
4
|
+
import { packageDirectory } from 'package-directory';
|
|
5
|
+
import path, { join, extname } from 'path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import url, { fileURLToPath, pathToFileURL } from 'url';
|
|
8
|
+
import YAML from 'yaml';
|
|
10
9
|
import { nanoid } from 'nanoid';
|
|
11
10
|
import { parse } from 'dotenv';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { execa, execaCommand } from 'execa';
|
|
13
|
+
import { globby } from 'globby';
|
|
14
|
+
import { stdin, stdout } from 'node:process';
|
|
15
|
+
import { createInterface } from 'readline/promises';
|
|
16
|
+
|
|
17
|
+
// Base root CLI defaults (shared; kept untyped here to avoid cross-layer deps).
|
|
18
|
+
const baseRootOptionDefaults = {
|
|
19
|
+
dotenvToken: '.env',
|
|
20
|
+
loadProcess: true,
|
|
21
|
+
logger: console,
|
|
22
|
+
paths: './',
|
|
23
|
+
pathsDelimiter: ' ',
|
|
24
|
+
privateToken: 'local',
|
|
25
|
+
scripts: {
|
|
26
|
+
'git-status': {
|
|
27
|
+
cmd: 'git branch --show-current && git status -s -u',
|
|
28
|
+
shell: true,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
shell: true,
|
|
32
|
+
vars: '',
|
|
33
|
+
varsAssignor: '=',
|
|
34
|
+
varsDelimiter: ' ',
|
|
35
|
+
// tri-state flags default to unset unless explicitly provided
|
|
36
|
+
// (debug/log/exclude* resolved via flag utils)
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const baseGetDotenvCliOptions = baseRootOptionDefaults;
|
|
40
|
+
|
|
41
|
+
/** @internal */
|
|
42
|
+
const isPlainObject = (value) => value !== null &&
|
|
43
|
+
typeof value === 'object' &&
|
|
44
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
45
|
+
const mergeInto = (target, source) => {
|
|
46
|
+
for (const [key, sVal] of Object.entries(source)) {
|
|
47
|
+
if (sVal === undefined)
|
|
48
|
+
continue; // do not overwrite with undefined
|
|
49
|
+
const tVal = target[key];
|
|
50
|
+
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
51
|
+
target[key] = mergeInto({ ...tVal }, sVal);
|
|
52
|
+
}
|
|
53
|
+
else if (isPlainObject(sVal)) {
|
|
54
|
+
target[key] = mergeInto({}, sVal);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
target[key] = sVal;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return target;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Perform a deep defaults-style merge across plain objects. *
|
|
64
|
+
* - Only merges plain objects (prototype === Object.prototype).
|
|
65
|
+
* - Arrays and non-objects are replaced, not merged.
|
|
66
|
+
* - `undefined` values are ignored and do not overwrite prior values.
|
|
67
|
+
*
|
|
68
|
+
* @typeParam T - The resulting shape after merging all layers.
|
|
69
|
+
* @param layers - Zero or more partial layers in ascending precedence order.
|
|
70
|
+
* @returns The merged object typed as {@link T}.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* defaultsDeep(\{ a: 1, nested: \{ b: 2 \} \}, \{ nested: \{ b: 3, c: 4 \} \})
|
|
74
|
+
* =\> \{ a: 1, nested: \{ b: 3, c: 4 \} \}
|
|
75
|
+
*/
|
|
76
|
+
const defaultsDeep = (...layers) => {
|
|
77
|
+
const result = layers
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.reduce((acc, layer) => mergeInto(acc, layer), {});
|
|
80
|
+
return result;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/GetDotenvOptions.ts
|
|
84
|
+
const getDotenvOptionsFilename = 'getdotenv.config.json';
|
|
85
|
+
/**
|
|
86
|
+
* Converts programmatic CLI options to `getDotenv` options. *
|
|
87
|
+
* @param cliOptions - CLI options. Defaults to `{}`.
|
|
88
|
+
*
|
|
89
|
+
* @returns `getDotenv` options.
|
|
90
|
+
*/
|
|
91
|
+
const getDotenvCliOptions2Options = ({ paths, pathsDelimiter, pathsDelimiterPattern, vars, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, ...rest }) => {
|
|
92
|
+
/**
|
|
93
|
+
* Convert CLI-facing string options into {@link GetDotenvOptions}.
|
|
94
|
+
*
|
|
95
|
+
* - 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`
|
|
96
|
+
* pairs (configurable delimiters) into a {@link ProcessEnv}.
|
|
97
|
+
* - Drops CLI-only keys that have no programmatic equivalent.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* Follows exact-optional semantics by not emitting undefined-valued entries.
|
|
101
|
+
*/
|
|
102
|
+
// Drop CLI-only keys (debug/scripts) without relying on Record casts.
|
|
103
|
+
// Create a shallow copy then delete optional CLI-only keys if present.
|
|
104
|
+
const restObj = { ...rest };
|
|
105
|
+
delete restObj.debug;
|
|
106
|
+
delete restObj.scripts;
|
|
107
|
+
const splitBy = (value, delim, pattern) => (value ? value.split(pattern ? RegExp(pattern) : (delim ?? ' ')) : []);
|
|
108
|
+
// Tolerate vars as either a CLI string ("A=1 B=2") or an object map.
|
|
109
|
+
let parsedVars;
|
|
110
|
+
if (typeof vars === 'string') {
|
|
111
|
+
const kvPairs = splitBy(vars, varsDelimiter, varsDelimiterPattern).map((v) => v.split(varsAssignorPattern
|
|
112
|
+
? RegExp(varsAssignorPattern)
|
|
113
|
+
: (varsAssignor ?? '=')));
|
|
114
|
+
parsedVars = Object.fromEntries(kvPairs);
|
|
115
|
+
}
|
|
116
|
+
else if (vars && typeof vars === 'object' && !Array.isArray(vars)) {
|
|
117
|
+
// Keep only string or undefined values to match ProcessEnv.
|
|
118
|
+
const entries = Object.entries(vars).filter(([k, v]) => typeof k === 'string' && (typeof v === 'string' || v === undefined));
|
|
119
|
+
parsedVars = Object.fromEntries(entries);
|
|
120
|
+
}
|
|
121
|
+
// Drop undefined-valued entries at the converter stage to match ProcessEnv
|
|
122
|
+
// expectations and the compat test assertions.
|
|
123
|
+
if (parsedVars) {
|
|
124
|
+
parsedVars = Object.fromEntries(Object.entries(parsedVars).filter(([, v]) => v !== undefined));
|
|
125
|
+
}
|
|
126
|
+
// Tolerate paths as either a delimited string or string[]
|
|
127
|
+
// Use a locally cast union type to avoid lint warnings about always-falsy conditions
|
|
128
|
+
// under the RootOptionsShape (which declares paths as string | undefined).
|
|
129
|
+
const pathsAny = paths;
|
|
130
|
+
const pathsOut = Array.isArray(pathsAny)
|
|
131
|
+
? pathsAny.filter((p) => typeof p === 'string')
|
|
132
|
+
: splitBy(pathsAny, pathsDelimiter, pathsDelimiterPattern);
|
|
133
|
+
// Preserve exactOptionalPropertyTypes: only include keys when defined.
|
|
134
|
+
return {
|
|
135
|
+
...restObj,
|
|
136
|
+
...(pathsOut.length > 0 ? { paths: pathsOut } : {}),
|
|
137
|
+
...(parsedVars !== undefined ? { vars: parsedVars } : {}),
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
const resolveGetDotenvOptions = async (customOptions) => {
|
|
141
|
+
/**
|
|
142
|
+
* Resolve {@link GetDotenvOptions} by layering defaults in ascending precedence:
|
|
143
|
+
*
|
|
144
|
+
* 1. Base defaults derived from the CLI generator defaults
|
|
145
|
+
* ({@link baseGetDotenvCliOptions}).
|
|
146
|
+
* 2. Local project overrides from a `getdotenv.config.json` in the nearest
|
|
147
|
+
* package root (if present).
|
|
148
|
+
* 3. The provided {@link customOptions}.
|
|
149
|
+
*
|
|
150
|
+
* The result preserves explicit empty values and drops only `undefined`.
|
|
151
|
+
*
|
|
152
|
+
* @returns Fully-resolved {@link GetDotenvOptions}.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* const options = await resolveGetDotenvOptions({ env: 'dev' });
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
const localPkgDir = await packageDirectory();
|
|
160
|
+
const localOptionsPath = localPkgDir
|
|
161
|
+
? join(localPkgDir, getDotenvOptionsFilename)
|
|
162
|
+
: undefined;
|
|
163
|
+
const localOptions = (localOptionsPath && (await fs.exists(localOptionsPath))
|
|
164
|
+
? JSON.parse((await fs.readFile(localOptionsPath)).toString())
|
|
165
|
+
: {});
|
|
166
|
+
// Merge order: base < local < custom (custom has highest precedence)
|
|
167
|
+
const mergedCli = defaultsDeep(baseGetDotenvCliOptions, localOptions);
|
|
168
|
+
const defaultsFromCli = getDotenvCliOptions2Options(mergedCli);
|
|
169
|
+
const result = defaultsDeep(defaultsFromCli, customOptions);
|
|
170
|
+
return {
|
|
171
|
+
...result, // Keep explicit empty strings/zeros; drop only undefined
|
|
172
|
+
vars: Object.fromEntries(Object.entries(result.vars ?? {}).filter(([, v]) => v !== undefined)),
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Zod schemas for programmatic GetDotenv options.
|
|
178
|
+
*
|
|
179
|
+
* NOTE: These schemas are introduced without wiring to avoid behavior changes.
|
|
180
|
+
* Legacy paths continue to use existing types/logic. The new plugin host will
|
|
181
|
+
* use these schemas in strict mode; legacy paths will adopt them in warn mode
|
|
182
|
+
* later per the staged plan.
|
|
183
|
+
*/
|
|
184
|
+
// Minimal process env representation: string values or undefined to indicate "unset".
|
|
185
|
+
const processEnvSchema = z.record(z.string(), z.string().optional());
|
|
186
|
+
// RAW: all fields optional — undefined means "inherit" from lower layers.
|
|
187
|
+
const getDotenvOptionsSchemaRaw = z.object({
|
|
188
|
+
defaultEnv: z.string().optional(),
|
|
189
|
+
dotenvToken: z.string().optional(),
|
|
190
|
+
dynamicPath: z.string().optional(),
|
|
191
|
+
// Dynamic map is intentionally wide for now; refine once sources are normalized.
|
|
192
|
+
dynamic: z.record(z.string(), z.unknown()).optional(),
|
|
193
|
+
env: z.string().optional(),
|
|
194
|
+
excludeDynamic: z.boolean().optional(),
|
|
195
|
+
excludeEnv: z.boolean().optional(),
|
|
196
|
+
excludeGlobal: z.boolean().optional(),
|
|
197
|
+
excludePrivate: z.boolean().optional(),
|
|
198
|
+
excludePublic: z.boolean().optional(),
|
|
199
|
+
loadProcess: z.boolean().optional(),
|
|
200
|
+
log: z.boolean().optional(),
|
|
201
|
+
outputPath: z.string().optional(),
|
|
202
|
+
paths: z.array(z.string()).optional(),
|
|
203
|
+
privateToken: z.string().optional(),
|
|
204
|
+
vars: processEnvSchema.optional(),
|
|
205
|
+
// Host-only feature flag: guarded integration of config loader/overlay
|
|
206
|
+
useConfigLoader: z.boolean().optional(),
|
|
207
|
+
});
|
|
208
|
+
// RESOLVED: service-boundary contract (post-inheritance).
|
|
209
|
+
// For Step A, keep identical to RAW (no behavior change). Later stages will// materialize required defaults and narrow shapes as resolution is wired.
|
|
210
|
+
const getDotenvOptionsSchemaResolved = getDotenvOptionsSchemaRaw;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Zod schemas for configuration files discovered by the new loader. *
|
|
214
|
+
* Notes:
|
|
215
|
+
* - RAW: all fields optional; shapes are stringly-friendly (paths may be string[] or string).
|
|
216
|
+
* - RESOLVED: normalized shapes (paths always string[]).
|
|
217
|
+
* - For this step (JSON/YAML only), any defined `dynamic` will be rejected by the loader.
|
|
218
|
+
*/
|
|
219
|
+
// String-only env value map
|
|
220
|
+
const stringMap = z.record(z.string(), z.string());
|
|
221
|
+
const envStringMap = z.record(z.string(), stringMap);
|
|
222
|
+
// Allow string[] or single string for "paths" in RAW; normalize later.
|
|
223
|
+
const rawPathsSchema = z.union([z.array(z.string()), z.string()]).optional();
|
|
224
|
+
const getDotenvConfigSchemaRaw = z.object({
|
|
225
|
+
dotenvToken: z.string().optional(),
|
|
226
|
+
privateToken: z.string().optional(),
|
|
227
|
+
paths: rawPathsSchema,
|
|
228
|
+
loadProcess: z.boolean().optional(),
|
|
229
|
+
log: z.boolean().optional(),
|
|
230
|
+
shell: z.union([z.string(), z.boolean()]).optional(),
|
|
231
|
+
scripts: z.record(z.string(), z.unknown()).optional(), // Scripts validation left wide; generator validates elsewhere
|
|
232
|
+
vars: stringMap.optional(), // public, global
|
|
233
|
+
envVars: envStringMap.optional(), // public, per-env
|
|
234
|
+
// Dynamic in config (JS/TS only). JSON/YAML loader will reject if set.
|
|
235
|
+
dynamic: z.unknown().optional(),
|
|
236
|
+
// Per-plugin config bag; validated by plugins/host when used.
|
|
237
|
+
plugins: z.record(z.string(), z.unknown()).optional(),
|
|
238
|
+
});
|
|
239
|
+
// Normalize paths to string[]
|
|
240
|
+
const normalizePaths = (p) => p === undefined ? undefined : Array.isArray(p) ? p : [p];
|
|
241
|
+
const getDotenvConfigSchemaResolved = getDotenvConfigSchemaRaw.transform((raw) => ({
|
|
242
|
+
...raw,
|
|
243
|
+
paths: normalizePaths(raw.paths),
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
// Discovery candidates (first match wins per scope/privacy).
|
|
247
|
+
// Order preserves historical JSON/YAML precedence; JS/TS added afterwards.
|
|
248
|
+
const PUBLIC_FILENAMES = [
|
|
249
|
+
'getdotenv.config.json',
|
|
250
|
+
'getdotenv.config.yaml',
|
|
251
|
+
'getdotenv.config.yml',
|
|
252
|
+
'getdotenv.config.js',
|
|
253
|
+
'getdotenv.config.mjs',
|
|
254
|
+
'getdotenv.config.cjs',
|
|
255
|
+
'getdotenv.config.ts',
|
|
256
|
+
'getdotenv.config.mts',
|
|
257
|
+
'getdotenv.config.cts',
|
|
258
|
+
];
|
|
259
|
+
const LOCAL_FILENAMES = [
|
|
260
|
+
'getdotenv.config.local.json',
|
|
261
|
+
'getdotenv.config.local.yaml',
|
|
262
|
+
'getdotenv.config.local.yml',
|
|
263
|
+
'getdotenv.config.local.js',
|
|
264
|
+
'getdotenv.config.local.mjs',
|
|
265
|
+
'getdotenv.config.local.cjs',
|
|
266
|
+
'getdotenv.config.local.ts',
|
|
267
|
+
'getdotenv.config.local.mts',
|
|
268
|
+
'getdotenv.config.local.cts',
|
|
269
|
+
];
|
|
270
|
+
const isYaml = (p) => ['.yaml', '.yml'].includes(extname(p).toLowerCase());
|
|
271
|
+
const isJson = (p) => extname(p).toLowerCase() === '.json';
|
|
272
|
+
const isJsOrTs = (p) => ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].includes(extname(p).toLowerCase());
|
|
273
|
+
// --- Internal JS/TS module loader helpers (default export) ---
|
|
274
|
+
const importDefault$1 = async (fileUrl) => {
|
|
275
|
+
const mod = (await import(fileUrl));
|
|
276
|
+
return mod.default;
|
|
277
|
+
};
|
|
278
|
+
const cacheName = (absPath, suffix) => {
|
|
279
|
+
// sanitized filename with suffix; recompile on mtime changes not tracked here (simplified)
|
|
280
|
+
const base = path.basename(absPath).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
281
|
+
return `${base}.${suffix}.mjs`;
|
|
282
|
+
};
|
|
283
|
+
const ensureDir$1 = async (dir) => {
|
|
284
|
+
await fs.ensureDir(dir);
|
|
285
|
+
return dir;
|
|
286
|
+
};
|
|
287
|
+
const loadJsTsDefault = async (absPath) => {
|
|
288
|
+
const fileUrl = pathToFileURL(absPath).toString();
|
|
289
|
+
const ext = extname(absPath).toLowerCase();
|
|
290
|
+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
|
|
291
|
+
return importDefault$1(fileUrl);
|
|
292
|
+
}
|
|
293
|
+
// Try direct import first in case a TS loader is active.
|
|
294
|
+
try {
|
|
295
|
+
const val = await importDefault$1(fileUrl);
|
|
296
|
+
if (val)
|
|
297
|
+
return val;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* fallthrough */
|
|
301
|
+
}
|
|
302
|
+
// esbuild bundle to a temp ESM file
|
|
303
|
+
try {
|
|
304
|
+
const esbuild = (await import('esbuild'));
|
|
305
|
+
const outDir = await ensureDir$1(path.resolve('.tsbuild', 'getdotenv-config'));
|
|
306
|
+
const outfile = path.join(outDir, cacheName(absPath, 'bundle'));
|
|
307
|
+
await esbuild.build({
|
|
308
|
+
entryPoints: [absPath],
|
|
309
|
+
bundle: true,
|
|
310
|
+
platform: 'node',
|
|
311
|
+
format: 'esm',
|
|
312
|
+
target: 'node20',
|
|
313
|
+
outfile,
|
|
314
|
+
sourcemap: false,
|
|
315
|
+
logLevel: 'silent',
|
|
316
|
+
});
|
|
317
|
+
return await importDefault$1(pathToFileURL(outfile).toString());
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
/* fallthrough to TS transpile */
|
|
321
|
+
}
|
|
322
|
+
// typescript.transpileModule simple transpile (single-file)
|
|
323
|
+
try {
|
|
324
|
+
const ts = (await import('typescript'));
|
|
325
|
+
const src = await fs.readFile(absPath, 'utf-8');
|
|
326
|
+
const out = ts.transpileModule(src, {
|
|
327
|
+
compilerOptions: {
|
|
328
|
+
module: 'ESNext',
|
|
329
|
+
target: 'ES2022',
|
|
330
|
+
moduleResolution: 'NodeNext',
|
|
331
|
+
},
|
|
332
|
+
}).outputText;
|
|
333
|
+
const outDir = await ensureDir$1(path.resolve('.tsbuild', 'getdotenv-config'));
|
|
334
|
+
const outfile = path.join(outDir, cacheName(absPath, 'ts'));
|
|
335
|
+
await fs.writeFile(outfile, out, 'utf-8');
|
|
336
|
+
return await importDefault$1(pathToFileURL(outfile).toString());
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
throw new Error(`Unable to load JS/TS config: ${absPath}. Install 'esbuild' for robust bundling or ensure a TS loader.`);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* Discover JSON/YAML config files in the packaged root and project root.
|
|
344
|
+
* Order: packaged public → project public → project local. */
|
|
345
|
+
const discoverConfigFiles = async (importMetaUrl) => {
|
|
346
|
+
const files = [];
|
|
347
|
+
// Packaged root via importMetaUrl (optional)
|
|
348
|
+
if (importMetaUrl) {
|
|
349
|
+
const fromUrl = fileURLToPath(importMetaUrl);
|
|
350
|
+
const packagedRoot = await packageDirectory({ cwd: fromUrl });
|
|
351
|
+
if (packagedRoot) {
|
|
352
|
+
for (const name of PUBLIC_FILENAMES) {
|
|
353
|
+
const p = join(packagedRoot, name);
|
|
354
|
+
if (await fs.pathExists(p)) {
|
|
355
|
+
files.push({ path: p, privacy: 'public', scope: 'packaged' });
|
|
356
|
+
break; // only one public file expected per scope
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// By policy, packaged .local is not expected; skip even if present.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Project root (from current working directory)
|
|
363
|
+
const projectRoot = await packageDirectory();
|
|
364
|
+
if (projectRoot) {
|
|
365
|
+
for (const name of PUBLIC_FILENAMES) {
|
|
366
|
+
const p = join(projectRoot, name);
|
|
367
|
+
if (await fs.pathExists(p)) {
|
|
368
|
+
files.push({ path: p, privacy: 'public', scope: 'project' });
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const name of LOCAL_FILENAMES) {
|
|
373
|
+
const p = join(projectRoot, name);
|
|
374
|
+
if (await fs.pathExists(p)) {
|
|
375
|
+
files.push({ path: p, privacy: 'local', scope: 'project' });
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return files;
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Load a single config file (JSON/YAML). JS/TS is not supported in this step.
|
|
384
|
+
* Validates with Zod RAW schema, then normalizes to RESOLVED.
|
|
385
|
+
*
|
|
386
|
+
* For JSON/YAML: if a "dynamic" property is present, throws with guidance.
|
|
387
|
+
* For JS/TS: default export is loaded; "dynamic" is allowed.
|
|
388
|
+
*/
|
|
389
|
+
const loadConfigFile = async (filePath) => {
|
|
390
|
+
let raw = {};
|
|
391
|
+
try {
|
|
392
|
+
const abs = path.resolve(filePath);
|
|
393
|
+
if (isJsOrTs(abs)) {
|
|
394
|
+
// JS/TS support: load default export via robust pipeline.
|
|
395
|
+
const mod = await loadJsTsDefault(abs);
|
|
396
|
+
raw = mod ?? {};
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
const txt = await fs.readFile(abs, 'utf-8');
|
|
400
|
+
raw = isJson(abs) ? JSON.parse(txt) : isYaml(abs) ? YAML.parse(txt) : {};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
throw new Error(`Failed to read/parse config: ${filePath}. ${String(err)}`);
|
|
405
|
+
}
|
|
406
|
+
// Validate RAW
|
|
407
|
+
const parsed = getDotenvConfigSchemaRaw.safeParse(raw);
|
|
408
|
+
if (!parsed.success) {
|
|
409
|
+
const msgs = parsed.error.issues
|
|
410
|
+
.map((i) => `${i.path.join('.')}: ${i.message}`)
|
|
411
|
+
.join('\n');
|
|
412
|
+
throw new Error(`Invalid config ${filePath}:\n${msgs}`);
|
|
413
|
+
}
|
|
414
|
+
// Disallow dynamic in JSON/YAML; allow in JS/TS
|
|
415
|
+
if (!isJsOrTs(filePath) && parsed.data.dynamic !== undefined) {
|
|
416
|
+
throw new Error(`Config ${filePath} specifies "dynamic"; JSON/YAML configs cannot include dynamic in this step. Use JS/TS config.`);
|
|
417
|
+
}
|
|
418
|
+
return getDotenvConfigSchemaResolved.parse(parsed.data);
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* Discover and load configs into resolved shapes, ordered by scope/privacy.
|
|
422
|
+
* JSON/YAML/JS/TS supported; first match per scope/privacy applies.
|
|
423
|
+
*/
|
|
424
|
+
const resolveGetDotenvConfigSources = async (importMetaUrl) => {
|
|
425
|
+
const discovered = await discoverConfigFiles(importMetaUrl);
|
|
426
|
+
const result = {};
|
|
427
|
+
for (const f of discovered) {
|
|
428
|
+
const cfg = await loadConfigFile(f.path);
|
|
429
|
+
if (f.scope === 'packaged') {
|
|
430
|
+
// packaged public only
|
|
431
|
+
result.packaged = cfg;
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
result.project ??= {};
|
|
435
|
+
if (f.privacy === 'public')
|
|
436
|
+
result.project.public = cfg;
|
|
437
|
+
else
|
|
438
|
+
result.project.local = cfg;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return result;
|
|
442
|
+
};
|
|
12
443
|
|
|
13
444
|
/**
|
|
14
445
|
* Dotenv expansion utilities.
|
|
@@ -146,273 +577,558 @@ const dotenvExpandAll = (values = {}, options = {}) => Object.keys(values).reduc
|
|
|
146
577
|
*/
|
|
147
578
|
const dotenvExpandFromProcessEnv = (value) => dotenvExpand(value, process.env);
|
|
148
579
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
shell: true,
|
|
163
|
-
vars: '',
|
|
164
|
-
varsAssignor: '=',
|
|
165
|
-
varsDelimiter: ' ',
|
|
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);
|
|
166
593
|
};
|
|
167
|
-
|
|
168
594
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
|
171
599
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* @returns Resolved command string (falls back to the provided command).
|
|
600
|
+
* Programmatic explicit vars (if provided) override all config slices.
|
|
601
|
+
* Progressive expansion is applied within each slice.
|
|
175
602
|
*/
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
: (
|
|
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
|
+
|
|
179
620
|
/**
|
|
180
|
-
*
|
|
181
|
-
* - If the script entry is an object, prefer its `shell` override.
|
|
182
|
-
* - Otherwise use the provided `shell` (string | boolean).
|
|
621
|
+
* Asynchronously read a dotenv file & parse it into an object.
|
|
183
622
|
*
|
|
184
|
-
* @param
|
|
185
|
-
* @
|
|
186
|
-
* @param shell - Global shell preference (string | boolean).
|
|
623
|
+
* @param path - Path to dotenv file.
|
|
624
|
+
* @returns The parsed dotenv object.
|
|
187
625
|
*/
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
|
|
193
|
-
let cwd = process.cwd();
|
|
194
|
-
if (pkgCwd) {
|
|
195
|
-
const pkgDir = await packageDirectory();
|
|
196
|
-
if (!pkgDir) {
|
|
197
|
-
logger.error('No package directory found.');
|
|
198
|
-
process.exit(0);
|
|
199
|
-
}
|
|
200
|
-
cwd = pkgDir;
|
|
626
|
+
const readDotenv = async (path) => {
|
|
627
|
+
try {
|
|
628
|
+
return (await fs.exists(path)) ? parse(await fs.readFile(path)) : {};
|
|
201
629
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
cwd: absRootPath,
|
|
205
|
-
expandDirectories: false,
|
|
206
|
-
onlyDirectories: true,
|
|
207
|
-
absolute: true,
|
|
208
|
-
});
|
|
209
|
-
if (!paths.length) {
|
|
210
|
-
logger.error(`No paths found for globs '${globs}' at '${absRootPath}'.`);
|
|
211
|
-
process.exit(0);
|
|
630
|
+
catch {
|
|
631
|
+
return {};
|
|
212
632
|
}
|
|
213
|
-
return { absRootPath, paths };
|
|
214
633
|
};
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}));
|
|
219
667
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
|
|
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;
|
|
246
827
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
: undefined,
|
|
260
|
-
},
|
|
261
|
-
stdio: 'inherit',
|
|
262
|
-
shell, // already normalized to string | boolean | URL
|
|
263
|
-
});
|
|
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
|
+
}
|
|
264
840
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.`);
|
|
268
852
|
}
|
|
269
853
|
}
|
|
270
854
|
}
|
|
271
|
-
|
|
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;
|
|
272
875
|
};
|
|
273
876
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
globs,
|
|
297
|
-
ignoreErrors,
|
|
298
|
-
list,
|
|
299
|
-
logger,
|
|
300
|
-
pkgCwd,
|
|
301
|
-
rootPath,
|
|
302
|
-
// execa expects string | boolean | URL for `shell`. We normalize earlier;
|
|
303
|
-
// scripts[name].shell overrides take precedence and may be boolean or string.
|
|
304
|
-
shell: resolveShell(getDotenvCliOptions.scripts, command, getDotenvCliOptions.shell),
|
|
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,
|
|
305
899
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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' });
|
|
334
942
|
}
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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 });
|
|
349
1015
|
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
+
}
|
|
381
1084
|
|
|
382
1085
|
/**
|
|
383
|
-
*
|
|
384
|
-
*
|
|
1086
|
+
* Attach legacy root flags to a Commander program.
|
|
1087
|
+
* Uses provided defaults to render help labels without coupling to generators.
|
|
385
1088
|
*/
|
|
386
|
-
const
|
|
387
|
-
const {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
.name(alias)
|
|
393
|
-
.description(description)
|
|
1089
|
+
const attachRootOptions = (program, defaults, opts) => {
|
|
1090
|
+
const { defaultEnv, dotenvToken, dynamicPath, env, excludeDynamic, excludeEnv, excludeGlobal, excludePrivate, excludePublic, loadProcess, log, outputPath, paths, pathsDelimiter, pathsDelimiterPattern, privateToken, scripts, shell, varsAssignor, varsAssignorPattern, varsDelimiter, varsDelimiterPattern, } = defaults ?? {};
|
|
1091
|
+
const va = typeof defaults?.varsAssignor === 'string' ? defaults.varsAssignor : '=';
|
|
1092
|
+
const vd = typeof defaults?.varsDelimiter === 'string' ? defaults.varsDelimiter : ' ';
|
|
1093
|
+
// Build initial chain.
|
|
1094
|
+
let p = program
|
|
394
1095
|
.enablePositionalOptions()
|
|
395
1096
|
.passThroughOptions()
|
|
396
|
-
.option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env)
|
|
397
|
-
|
|
1097
|
+
.option('-e, --env <string>', `target environment (dotenv-expanded)`, dotenvExpandFromProcessEnv, env);
|
|
1098
|
+
p = p.option('-v, --vars <string>', `extra variables expressed as delimited key-value pairs (dotenv-expanded): ${[
|
|
398
1099
|
['KEY1', 'VAL1'],
|
|
399
1100
|
['KEY2', 'VAL2'],
|
|
400
1101
|
]
|
|
401
|
-
.map((v) => v.join(
|
|
402
|
-
.join(
|
|
403
|
-
|
|
1102
|
+
.map((v) => v.join(va))
|
|
1103
|
+
.join(vd)}`, dotenvExpandFromProcessEnv);
|
|
1104
|
+
// Optional legacy root command flag (kept for generated CLI compatibility).
|
|
1105
|
+
// Default is OFF; the generator opts in explicitly.
|
|
1106
|
+
if (opts?.includeCommandOption === true) {
|
|
1107
|
+
p = p.option('-c, --command <string>', 'command executed according to the --shell option, conflicts with cmd subcommand (dotenv-expanded)', dotenvExpandFromProcessEnv);
|
|
1108
|
+
}
|
|
1109
|
+
p = p
|
|
404
1110
|
.option('-o, --output-path <string>', 'consolidated output file (dotenv-expanded)', dotenvExpandFromProcessEnv, outputPath)
|
|
405
1111
|
.addOption(new Option('-s, --shell [string]', (() => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1112
|
+
let defaultLabel = '';
|
|
1113
|
+
if (shell !== undefined) {
|
|
1114
|
+
if (typeof shell === 'boolean') {
|
|
1115
|
+
defaultLabel = ' (default OS shell)';
|
|
1116
|
+
}
|
|
1117
|
+
else if (typeof shell === 'string') {
|
|
1118
|
+
// Safe string interpolation
|
|
1119
|
+
defaultLabel = ` (default ${shell})`;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
409
1122
|
return `command execution shell, no argument for default OS shell or provide shell string${defaultLabel}`;
|
|
410
1123
|
})()).conflicts('shellOff'))
|
|
411
1124
|
.addOption(new Option('-S, --shell-off', `command execution shell OFF${!shell ? ' (default)' : ''}`).conflicts('shell'))
|
|
412
1125
|
.addOption(new Option('-p, --load-process', `load variables to process.env ON${loadProcess ? ' (default)' : ''}`).conflicts('loadProcessOff'))
|
|
413
1126
|
.addOption(new Option('-P, --load-process-off', `load variables to process.env OFF${!loadProcess ? ' (default)' : ''}`).conflicts('loadProcess'))
|
|
414
|
-
.addOption(new Option('-a, --exclude-all', `exclude all dotenv variables from loading ON${
|
|
415
|
-
|
|
1127
|
+
.addOption(new Option('-a, --exclude-all', `exclude all dotenv variables from loading ON${excludeDynamic &&
|
|
1128
|
+
((excludeEnv && excludeGlobal) || (excludePrivate && excludePublic))
|
|
1129
|
+
? ' (default)'
|
|
1130
|
+
: ''}`).conflicts('excludeAllOff'))
|
|
1131
|
+
.addOption(new Option('-A, --exclude-all-off', `exclude all dotenv variables from loading OFF (default)`).conflicts('excludeAll'))
|
|
416
1132
|
.addOption(new Option('-z, --exclude-dynamic', `exclude dynamic dotenv variables from loading ON${excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamicOff'))
|
|
417
1133
|
.addOption(new Option('-Z, --exclude-dynamic-off', `exclude dynamic dotenv variables from loading OFF${!excludeDynamic ? ' (default)' : ''}`).conflicts('excludeDynamic'))
|
|
418
1134
|
.addOption(new Option('-n, --exclude-env', `exclude environment-specific dotenv variables from loading${excludeEnv ? ' (default)' : ''}`).conflicts('excludeEnvOff'))
|
|
@@ -425,8 +1141,7 @@ const createRootCommand = (opts) => {
|
|
|
425
1141
|
.addOption(new Option('-U, --exclude-public-off', `exclude public dotenv variables from loading OFF${!excludePublic ? ' (default)' : ''}`).conflicts('excludePublic'))
|
|
426
1142
|
.addOption(new Option('-l, --log', `console log loaded variables ON${log ? ' (default)' : ''}`).conflicts('logOff'))
|
|
427
1143
|
.addOption(new Option('-L, --log-off', `console log loaded variables OFF${!log ? ' (default)' : ''}`).conflicts('log'))
|
|
428
|
-
.
|
|
429
|
-
.addOption(new Option('-D, --debug-off', `debug mode OFF${!debug ? ' (default)' : ''}`).conflicts('debug'))
|
|
1144
|
+
.option('--capture', 'capture child process stdio for commands (tests/CI)')
|
|
430
1145
|
.option('--default-env <string>', 'default target environment', dotenvExpandFromProcessEnv, defaultEnv)
|
|
431
1146
|
.option('--dotenv-token <string>', 'dotenv-expanded token indicating a dotenv file', dotenvExpandFromProcessEnv, dotenvToken)
|
|
432
1147
|
.option('--dynamic-path <string>', 'dynamic variables path (.js or .ts; .ts is auto-compiled when esbuild is available, otherwise precompile)', dotenvExpandFromProcessEnv, dynamicPath)
|
|
@@ -438,579 +1153,1918 @@ const createRootCommand = (opts) => {
|
|
|
438
1153
|
.option('--vars-delimiter-pattern <string>', 'vars delimiter regex pattern', varsDelimiterPattern)
|
|
439
1154
|
.option('--vars-assignor <string>', 'vars assignment operator string', varsAssignor)
|
|
440
1155
|
.option('--vars-assignor-pattern <string>', 'vars assignment operator regex pattern', varsAssignorPattern)
|
|
1156
|
+
// Hidden scripts pipe-through (stringified)
|
|
441
1157
|
.addOption(new Option('--scripts <string>')
|
|
442
1158
|
.default(JSON.stringify(scripts))
|
|
443
|
-
.hideHelp())
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
return
|
|
1159
|
+
.hideHelp());
|
|
1160
|
+
// Diagnostics: opt-in tracing; optional variadic keys after the flag.
|
|
1161
|
+
p = p.option('--trace [keys...]', 'emit diagnostics for child env composition (optional keys)');
|
|
1162
|
+
return p;
|
|
447
1163
|
};
|
|
448
1164
|
|
|
449
|
-
/** @internal */
|
|
450
|
-
const isPlainObject = (value) => value !== null &&
|
|
451
|
-
typeof value === 'object' &&
|
|
452
|
-
Object.getPrototypeOf(value) === Object.prototype;
|
|
453
|
-
const mergeInto = (target, source) => {
|
|
454
|
-
for (const [key, sVal] of Object.entries(source)) {
|
|
455
|
-
if (sVal === undefined)
|
|
456
|
-
continue; // do not overwrite with undefined
|
|
457
|
-
const tVal = target[key];
|
|
458
|
-
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
459
|
-
target[key] = mergeInto({ ...tVal }, sVal);
|
|
460
|
-
}
|
|
461
|
-
else if (isPlainObject(sVal)) {
|
|
462
|
-
target[key] = mergeInto({}, sVal);
|
|
463
|
-
}
|
|
464
|
-
else {
|
|
465
|
-
target[key] = sVal;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return target;
|
|
469
|
-
};
|
|
470
1165
|
/**
|
|
471
|
-
*
|
|
472
|
-
* -
|
|
473
|
-
* -
|
|
474
|
-
* -
|
|
1166
|
+
* Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
|
|
1167
|
+
* - If the user explicitly enabled the flag, return true.
|
|
1168
|
+
* - If the user explicitly disabled (the "...-off" variant), return undefined (unset).
|
|
1169
|
+
* - Otherwise, adopt the default (true → set; false/undefined → unset).
|
|
475
1170
|
*
|
|
476
|
-
* @
|
|
477
|
-
* @param
|
|
478
|
-
* @
|
|
1171
|
+
* @param exclude - The "on" flag value as parsed by Commander.
|
|
1172
|
+
* @param excludeOff - The "off" toggle (present when specified) as parsed by Commander.
|
|
1173
|
+
* @param defaultValue - The generator default to adopt when no explicit toggle is present.
|
|
1174
|
+
* @returns boolean | undefined — use `undefined` to indicate "unset" (do not emit).
|
|
479
1175
|
*
|
|
480
1176
|
* @example
|
|
481
|
-
*
|
|
482
|
-
*
|
|
1177
|
+
* ```ts
|
|
1178
|
+
* resolveExclusion(undefined, undefined, true); // => true
|
|
1179
|
+
* ```
|
|
483
1180
|
*/
|
|
484
|
-
const
|
|
485
|
-
const result = layers
|
|
486
|
-
.filter(Boolean)
|
|
487
|
-
.reduce((acc, layer) => mergeInto(acc, layer), {});
|
|
488
|
-
return result;
|
|
489
|
-
};
|
|
490
|
-
|
|
491
|
-
// src/GetDotenvOptions.ts
|
|
492
|
-
const getDotenvOptionsFilename = 'getdotenv.config.json';
|
|
1181
|
+
const resolveExclusion = (exclude, excludeOff, defaultValue) => exclude ? true : excludeOff ? undefined : defaultValue ? true : undefined;
|
|
493
1182
|
/**
|
|
494
|
-
*
|
|
1183
|
+
* Resolve an optional flag with "--exclude-all" overrides.
|
|
1184
|
+
* If excludeAll is set and the individual "...-off" is not, force true.
|
|
1185
|
+
* If excludeAllOff is set and the individual flag is not explicitly set, unset.
|
|
1186
|
+
* Otherwise, adopt the default (true → set; false/undefined → unset).
|
|
495
1187
|
*
|
|
496
|
-
* @param
|
|
1188
|
+
* @param exclude - Individual include/exclude flag.
|
|
1189
|
+
* @param excludeOff - Individual "...-off" flag.
|
|
1190
|
+
* @param defaultValue - Default for the individual flag.
|
|
1191
|
+
* @param excludeAll - Global "exclude-all" flag.
|
|
1192
|
+
* @param excludeAllOff - Global "exclude-all-off" flag.
|
|
497
1193
|
*
|
|
498
|
-
* @
|
|
1194
|
+
* @example
|
|
1195
|
+
* resolveExclusionAll(undefined, undefined, false, true, undefined) =\> true
|
|
499
1196
|
*/
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
1197
|
+
const resolveExclusionAll = (exclude, excludeOff, defaultValue, excludeAll, excludeAllOff) =>
|
|
1198
|
+
// Order of precedence:
|
|
1199
|
+
// 1) Individual explicit "on" wins outright.
|
|
1200
|
+
// 2) Individual explicit "off" wins over any global.
|
|
1201
|
+
// 3) Global exclude-all forces true when not explicitly turned off.
|
|
1202
|
+
// 4) Global exclude-all-off unsets when the individual wasn't explicitly enabled.
|
|
1203
|
+
// 5) Fall back to the default (true => set; false/undefined => unset).
|
|
1204
|
+
(() => {
|
|
1205
|
+
// Individual "on"
|
|
1206
|
+
if (exclude === true)
|
|
1207
|
+
return true;
|
|
1208
|
+
// Individual "off"
|
|
1209
|
+
if (excludeOff === true)
|
|
1210
|
+
return undefined;
|
|
1211
|
+
// Global "exclude-all" ON (unless explicitly turned off)
|
|
1212
|
+
if (excludeAll === true)
|
|
1213
|
+
return true;
|
|
1214
|
+
// Global "exclude-all-off" (unless explicitly enabled)
|
|
1215
|
+
if (excludeAllOff === true)
|
|
1216
|
+
return undefined;
|
|
1217
|
+
// Default
|
|
1218
|
+
return defaultValue ? true : undefined;
|
|
1219
|
+
})();
|
|
1220
|
+
/**
|
|
1221
|
+
* exactOptionalPropertyTypes-safe setter for optional boolean flags:
|
|
1222
|
+
* delete when undefined; assign when defined — without requiring an index signature on T.
|
|
1223
|
+
*
|
|
1224
|
+
* @typeParam T - Target object type.
|
|
1225
|
+
* @param obj - The object to write to.
|
|
1226
|
+
* @param key - The optional boolean property key of {@link T}.
|
|
1227
|
+
* @param value - The value to set or `undefined` to unset.
|
|
1228
|
+
*
|
|
1229
|
+
* @remarks
|
|
1230
|
+
* Writes through a local `Record<string, unknown>` view to avoid requiring an index signature on {@link T}.
|
|
1231
|
+
*/
|
|
1232
|
+
const setOptionalFlag = (obj, key, value) => {
|
|
1233
|
+
const target = obj;
|
|
1234
|
+
const k = key;
|
|
1235
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
1236
|
+
if (value === undefined)
|
|
1237
|
+
delete target[k];
|
|
1238
|
+
else
|
|
1239
|
+
target[k] = value;
|
|
527
1240
|
};
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
*
|
|
538
|
-
* The result preserves explicit empty values and drops only `undefined`.
|
|
539
|
-
*
|
|
540
|
-
* @returns Fully-resolved {@link GetDotenvOptions}.
|
|
541
|
-
*
|
|
542
|
-
* @example
|
|
543
|
-
* ```ts
|
|
544
|
-
* const options = await resolveGetDotenvOptions({ env: 'dev' });
|
|
545
|
-
* ```
|
|
546
|
-
*/
|
|
547
|
-
const localPkgDir = await packageDirectory();
|
|
548
|
-
const localOptionsPath = localPkgDir
|
|
549
|
-
? join(localPkgDir, getDotenvOptionsFilename)
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Merge and normalize raw Commander options (current + parent + defaults)
|
|
1244
|
+
* into a GetDotenvCliOptions-like object. Types are intentionally wide to
|
|
1245
|
+
* avoid cross-layer coupling; callers may cast as needed.
|
|
1246
|
+
*/
|
|
1247
|
+
const resolveCliOptions = (rawCliOptions, defaults, parentJson) => {
|
|
1248
|
+
const parent = typeof parentJson === 'string' && parentJson.length > 0
|
|
1249
|
+
? JSON.parse(parentJson)
|
|
550
1250
|
: undefined;
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1251
|
+
const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, scripts, shellOff, ...rest } = rawCliOptions;
|
|
1252
|
+
const current = { ...rest };
|
|
1253
|
+
if (typeof scripts === 'string') {
|
|
1254
|
+
try {
|
|
1255
|
+
current.scripts = JSON.parse(scripts);
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
// ignore parse errors; leave scripts undefined
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const merged = defaultsDeep({}, defaults, parent ?? {}, current);
|
|
1262
|
+
const d = defaults;
|
|
1263
|
+
setOptionalFlag(merged, 'debug', resolveExclusion(merged.debug, debugOff, d.debug));
|
|
1264
|
+
setOptionalFlag(merged, 'excludeDynamic', resolveExclusionAll(merged.excludeDynamic, excludeDynamicOff, d.excludeDynamic, excludeAll, excludeAllOff));
|
|
1265
|
+
setOptionalFlag(merged, 'excludeEnv', resolveExclusionAll(merged.excludeEnv, excludeEnvOff, d.excludeEnv, excludeAll, excludeAllOff));
|
|
1266
|
+
setOptionalFlag(merged, 'excludeGlobal', resolveExclusionAll(merged.excludeGlobal, excludeGlobalOff, d.excludeGlobal, excludeAll, excludeAllOff));
|
|
1267
|
+
setOptionalFlag(merged, 'excludePrivate', resolveExclusionAll(merged.excludePrivate, excludePrivateOff, d.excludePrivate, excludeAll, excludeAllOff));
|
|
1268
|
+
setOptionalFlag(merged, 'excludePublic', resolveExclusionAll(merged.excludePublic, excludePublicOff, d.excludePublic, excludeAll, excludeAllOff));
|
|
1269
|
+
setOptionalFlag(merged, 'log', resolveExclusion(merged.log, logOff, d.log));
|
|
1270
|
+
setOptionalFlag(merged, 'loadProcess', resolveExclusion(merged.loadProcess, loadProcessOff, d.loadProcess));
|
|
1271
|
+
// Normalize shell for predictability: explicit default shell per OS.
|
|
1272
|
+
const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
|
|
1273
|
+
let resolvedShell = merged.shell;
|
|
1274
|
+
if (shellOff)
|
|
1275
|
+
resolvedShell = false;
|
|
1276
|
+
else if (resolvedShell === true || resolvedShell === undefined) {
|
|
1277
|
+
resolvedShell = defaultShell;
|
|
1278
|
+
}
|
|
1279
|
+
else if (typeof resolvedShell !== 'string' &&
|
|
1280
|
+
typeof defaults.shell === 'string') {
|
|
1281
|
+
resolvedShell = defaults.shell;
|
|
1282
|
+
}
|
|
1283
|
+
merged.shell = resolvedShell;
|
|
1284
|
+
const cmd = typeof command === 'string' ? command : undefined;
|
|
1285
|
+
return cmd !== undefined ? { merged, command: cmd } : { merged };
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
GetDotenvCli.prototype.attachRootOptions = function (defaults, opts) {
|
|
1289
|
+
const d = (defaults ?? baseRootOptionDefaults);
|
|
1290
|
+
attachRootOptions(this, d, opts);
|
|
1291
|
+
return this;
|
|
1292
|
+
};
|
|
1293
|
+
GetDotenvCli.prototype.passOptions = function (defaults) {
|
|
1294
|
+
const d = (defaults ?? baseRootOptionDefaults);
|
|
1295
|
+
this.hook('preSubcommand', async (thisCommand) => {
|
|
1296
|
+
const raw = thisCommand.opts();
|
|
1297
|
+
const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
|
|
1298
|
+
// Persist merged options for nested invocations (batch exec).
|
|
1299
|
+
thisCommand.getDotenvCliOptions =
|
|
1300
|
+
merged;
|
|
1301
|
+
// Build service options and compute context (always-on config loader path).
|
|
1302
|
+
const serviceOptions = getDotenvCliOptions2Options(merged);
|
|
1303
|
+
await this.resolveAndLoad(serviceOptions);
|
|
1304
|
+
});
|
|
1305
|
+
// Also handle root-level flows (no subcommand) so option-aliases can run
|
|
1306
|
+
// with the same merged options and context without duplicating logic.
|
|
1307
|
+
this.hook('preAction', async (thisCommand) => {
|
|
1308
|
+
const raw = thisCommand.opts();
|
|
1309
|
+
const { merged } = resolveCliOptions(raw, d, process.env.getDotenvCliOptions);
|
|
1310
|
+
thisCommand.getDotenvCliOptions =
|
|
1311
|
+
merged;
|
|
1312
|
+
// Avoid duplicate heavy work if a context is already present.
|
|
1313
|
+
if (!this.getCtx()) {
|
|
1314
|
+
const serviceOptions = getDotenvCliOptions2Options(merged);
|
|
1315
|
+
await this.resolveAndLoad(serviceOptions);
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
return this;
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
// Minimal tokenizer for shell-off execution:
|
|
1322
|
+
// Splits by whitespace while preserving quoted segments (single or double quotes).
|
|
1323
|
+
const tokenize = (command) => {
|
|
1324
|
+
const out = [];
|
|
1325
|
+
let cur = '';
|
|
1326
|
+
let quote = null;
|
|
1327
|
+
for (let i = 0; i < command.length; i++) {
|
|
1328
|
+
const c = command.charAt(i);
|
|
1329
|
+
if (quote) {
|
|
1330
|
+
if (c === quote) {
|
|
1331
|
+
// Support doubled quotes inside a quoted segment (Windows/PowerShell style):
|
|
1332
|
+
// "" -> " and '' -> '
|
|
1333
|
+
const next = command.charAt(i + 1);
|
|
1334
|
+
if (next === quote) {
|
|
1335
|
+
cur += quote;
|
|
1336
|
+
i += 1; // skip the second quote
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
// end of quoted segment
|
|
1340
|
+
quote = null;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
else {
|
|
1344
|
+
cur += c;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
if (c === '"' || c === "'") {
|
|
1349
|
+
quote = c;
|
|
1350
|
+
}
|
|
1351
|
+
else if (/\s/.test(c)) {
|
|
1352
|
+
if (cur) {
|
|
1353
|
+
out.push(cur);
|
|
1354
|
+
cur = '';
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
else {
|
|
1358
|
+
cur += c;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (cur)
|
|
1363
|
+
out.push(cur);
|
|
1364
|
+
return out;
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
const dbg$1 = (...args) => {
|
|
1368
|
+
if (process.env.GETDOTENV_DEBUG) {
|
|
1369
|
+
// Use stderr to avoid interfering with stdout assertions
|
|
1370
|
+
console.error('[getdotenv:run]', ...args);
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
// Strip repeated symmetric outer quotes (single or double) until stable.
|
|
1374
|
+
// This is safe for argv arrays passed to execa (no quoting needed) and avoids
|
|
1375
|
+
// passing quote characters through to Node (e.g., for `node -e "<code>"`).
|
|
1376
|
+
// Handles stacked quotes from shells like PowerShell: """code""" -> code.
|
|
1377
|
+
const stripOuterQuotes = (s) => {
|
|
1378
|
+
let out = s;
|
|
1379
|
+
// Repeatedly trim only when the entire string is wrapped in matching quotes.
|
|
1380
|
+
// Stop as soon as the ends are asymmetric or no quotes remain.
|
|
1381
|
+
while (out.length >= 2) {
|
|
1382
|
+
const a = out.charAt(0);
|
|
1383
|
+
const b = out.charAt(out.length - 1);
|
|
1384
|
+
const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
|
|
1385
|
+
if (!symmetric)
|
|
1386
|
+
break;
|
|
1387
|
+
out = out.slice(1, -1);
|
|
1388
|
+
}
|
|
1389
|
+
return out;
|
|
1390
|
+
};
|
|
1391
|
+
// Extract exitCode/stdout/stderr from execa result or error in a tolerant way.
|
|
1392
|
+
const pickResult = (r) => {
|
|
1393
|
+
const exit = r.exitCode;
|
|
1394
|
+
const stdoutVal = r.stdout;
|
|
1395
|
+
const stderrVal = r.stderr;
|
|
558
1396
|
return {
|
|
559
|
-
|
|
560
|
-
|
|
1397
|
+
exitCode: typeof exit === 'number' ? exit : Number.NaN,
|
|
1398
|
+
stdout: typeof stdoutVal === 'string' ? stdoutVal : '',
|
|
1399
|
+
stderr: typeof stderrVal === 'string' ? stderrVal : '',
|
|
561
1400
|
};
|
|
562
1401
|
};
|
|
1402
|
+
// Convert NodeJS.ProcessEnv (string | undefined values) to the shape execa
|
|
1403
|
+
// expects (Readonly<Partial<Record<string, string>>>), dropping undefineds.
|
|
1404
|
+
const sanitizeEnv = (env) => {
|
|
1405
|
+
if (!env)
|
|
1406
|
+
return undefined;
|
|
1407
|
+
const entries = Object.entries(env).filter((e) => typeof e[1] === 'string');
|
|
1408
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
1409
|
+
};
|
|
1410
|
+
/**
|
|
1411
|
+
* Execute a command and capture stdout/stderr (buffered).
|
|
1412
|
+
* - Preserves plain vs shell behavior and argv/string normalization.
|
|
1413
|
+
* - Never re-emits stdout/stderr to parent; returns captured buffers.
|
|
1414
|
+
* - Supports optional timeout (ms).
|
|
1415
|
+
*/
|
|
1416
|
+
const runCommandResult = async (command, shell, opts = {}) => {
|
|
1417
|
+
const envSan = sanitizeEnv(opts.env);
|
|
1418
|
+
{
|
|
1419
|
+
let file;
|
|
1420
|
+
let args = [];
|
|
1421
|
+
if (Array.isArray(command)) {
|
|
1422
|
+
file = command[0];
|
|
1423
|
+
args = command.slice(1).map(stripOuterQuotes);
|
|
1424
|
+
}
|
|
1425
|
+
else {
|
|
1426
|
+
const tokens = tokenize(command);
|
|
1427
|
+
file = tokens[0];
|
|
1428
|
+
args = tokens.slice(1);
|
|
1429
|
+
}
|
|
1430
|
+
if (!file)
|
|
1431
|
+
return { exitCode: 0, stdout: '', stderr: '' };
|
|
1432
|
+
dbg$1('exec:capture (plain)', { file, args });
|
|
1433
|
+
try {
|
|
1434
|
+
const result = await execa(file, args, {
|
|
1435
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
1436
|
+
...(envSan !== undefined ? { env: envSan } : {}),
|
|
1437
|
+
stdio: 'pipe',
|
|
1438
|
+
...(opts.timeoutMs !== undefined
|
|
1439
|
+
? { timeout: opts.timeoutMs, killSignal: 'SIGKILL' }
|
|
1440
|
+
: {}),
|
|
1441
|
+
});
|
|
1442
|
+
const ok = pickResult(result);
|
|
1443
|
+
dbg$1('exit:capture (plain)', { exitCode: ok.exitCode });
|
|
1444
|
+
return ok;
|
|
1445
|
+
}
|
|
1446
|
+
catch (err) {
|
|
1447
|
+
const out = pickResult(err);
|
|
1448
|
+
dbg$1('exit:capture:error (plain)', { exitCode: out.exitCode });
|
|
1449
|
+
return out;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
1453
|
+
const runCommand = async (command, shell, opts) => {
|
|
1454
|
+
if (shell === false) {
|
|
1455
|
+
let file;
|
|
1456
|
+
let args = [];
|
|
1457
|
+
if (Array.isArray(command)) {
|
|
1458
|
+
file = command[0];
|
|
1459
|
+
args = command.slice(1).map(stripOuterQuotes);
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
const tokens = tokenize(command);
|
|
1463
|
+
file = tokens[0];
|
|
1464
|
+
args = tokens.slice(1);
|
|
1465
|
+
}
|
|
1466
|
+
if (!file)
|
|
1467
|
+
return 0;
|
|
1468
|
+
dbg$1('exec (plain)', { file, args, stdio: opts.stdio });
|
|
1469
|
+
// Build options without injecting undefined properties (exactOptionalPropertyTypes).
|
|
1470
|
+
const envSan = sanitizeEnv(opts.env);
|
|
1471
|
+
const plainOpts = {};
|
|
1472
|
+
if (opts.cwd !== undefined)
|
|
1473
|
+
plainOpts.cwd = opts.cwd;
|
|
1474
|
+
if (envSan !== undefined)
|
|
1475
|
+
plainOpts.env = envSan;
|
|
1476
|
+
if (opts.stdio !== undefined)
|
|
1477
|
+
plainOpts.stdio = opts.stdio;
|
|
1478
|
+
const result = await execa(file, args, plainOpts);
|
|
1479
|
+
if (opts.stdio === 'pipe' && result.stdout) {
|
|
1480
|
+
process.stdout.write(result.stdout + (result.stdout.endsWith('\n') ? '' : '\n'));
|
|
1481
|
+
}
|
|
1482
|
+
const exit = result?.exitCode;
|
|
1483
|
+
dbg$1('exit (plain)', { exitCode: exit });
|
|
1484
|
+
return typeof exit === 'number' ? exit : Number.NaN;
|
|
1485
|
+
}
|
|
1486
|
+
else {
|
|
1487
|
+
const commandStr = Array.isArray(command) ? command.join(' ') : command;
|
|
1488
|
+
dbg$1('exec (shell)', {
|
|
1489
|
+
shell: typeof shell === 'string' ? shell : 'custom',
|
|
1490
|
+
stdio: opts.stdio,
|
|
1491
|
+
command: commandStr,
|
|
1492
|
+
});
|
|
1493
|
+
const envSan = sanitizeEnv(opts.env);
|
|
1494
|
+
const shellOpts = { shell };
|
|
1495
|
+
if (opts.cwd !== undefined)
|
|
1496
|
+
shellOpts.cwd = opts.cwd;
|
|
1497
|
+
if (envSan !== undefined)
|
|
1498
|
+
shellOpts.env = envSan;
|
|
1499
|
+
if (opts.stdio !== undefined)
|
|
1500
|
+
shellOpts.stdio = opts.stdio;
|
|
1501
|
+
const result = await execaCommand(commandStr, shellOpts);
|
|
1502
|
+
const out = result?.stdout;
|
|
1503
|
+
if (opts.stdio === 'pipe' && out) {
|
|
1504
|
+
process.stdout.write(out + (out.endsWith('\n') ? '' : '\n'));
|
|
1505
|
+
}
|
|
1506
|
+
const exit = result?.exitCode;
|
|
1507
|
+
dbg$1('exit (shell)', { exitCode: exit });
|
|
1508
|
+
return typeof exit === 'number' ? exit : Number.NaN;
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
563
1511
|
|
|
564
1512
|
/**
|
|
565
|
-
*
|
|
1513
|
+
* Define a GetDotenv CLI plugin with compositional helpers.
|
|
1514
|
+
*
|
|
1515
|
+
* @example
|
|
1516
|
+
* const parent = definePlugin(\{ id: 'p', setup(cli) \{ /* ... *\/ \} \})
|
|
1517
|
+
* .use(childA)
|
|
1518
|
+
* .use(childB);
|
|
566
1519
|
*/
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
1520
|
+
const definePlugin = (spec) => {
|
|
1521
|
+
const { children = [], ...rest } = spec;
|
|
1522
|
+
const plugin = {
|
|
1523
|
+
...rest,
|
|
1524
|
+
children: [...children],
|
|
1525
|
+
use(child) {
|
|
1526
|
+
this.children.push(child);
|
|
1527
|
+
return this;
|
|
1528
|
+
},
|
|
572
1529
|
};
|
|
573
|
-
|
|
574
|
-
? await packageDirectory({
|
|
575
|
-
cwd: fileURLToPath(importMetaUrl),
|
|
576
|
-
})
|
|
577
|
-
: undefined;
|
|
578
|
-
const globalOptionsPath = globalPkgDir
|
|
579
|
-
? join(globalPkgDir, getDotenvOptionsFilename)
|
|
580
|
-
: undefined;
|
|
581
|
-
const globalOptions = (globalOptionsPath && (await fs.exists(globalOptionsPath))
|
|
582
|
-
? JSON.parse((await fs.readFile(globalOptionsPath)).toString())
|
|
583
|
-
: {});
|
|
584
|
-
const localPkgDir = await packageDirectory();
|
|
585
|
-
const localOptionsPath = localPkgDir
|
|
586
|
-
? join(localPkgDir, getDotenvOptionsFilename)
|
|
587
|
-
: undefined;
|
|
588
|
-
const localOptions = (localOptionsPath &&
|
|
589
|
-
localOptionsPath !== globalOptionsPath &&
|
|
590
|
-
(await fs.exists(localOptionsPath))
|
|
591
|
-
? JSON.parse((await fs.readFile(localOptionsPath)).toString())
|
|
592
|
-
: {});
|
|
593
|
-
// Merge order: base < global < local < custom
|
|
594
|
-
const merged = defaultsDeep(baseOptions, globalOptions, localOptions, customOptions);
|
|
595
|
-
return merged;
|
|
1530
|
+
return plugin;
|
|
596
1531
|
};
|
|
597
1532
|
|
|
598
1533
|
/**
|
|
599
|
-
*
|
|
1534
|
+
* Batch services (neutral): resolve command and shell settings.
|
|
1535
|
+
* Shared by the generator path and the batch plugin to avoid circular deps.
|
|
1536
|
+
*/
|
|
1537
|
+
/**
|
|
1538
|
+
* Resolve a command string from the {@link Scripts} table.
|
|
1539
|
+
* A script may be expressed as a string or an object with a `cmd` property.
|
|
600
1540
|
*
|
|
601
|
-
* @param
|
|
602
|
-
* @
|
|
1541
|
+
* @param scripts - Optional scripts table.
|
|
1542
|
+
* @param command - User-provided command name or string.
|
|
1543
|
+
* @returns Resolved command string (falls back to the provided command).
|
|
603
1544
|
*/
|
|
604
|
-
const
|
|
1545
|
+
const resolveCommand = (scripts, command) => scripts && typeof scripts[command] === 'object'
|
|
1546
|
+
? scripts[command].cmd
|
|
1547
|
+
: (scripts?.[command] ?? command);
|
|
1548
|
+
/**
|
|
1549
|
+
* Resolve the shell setting for a given command:
|
|
1550
|
+
* - If the script entry is an object, prefer its `shell` override.
|
|
1551
|
+
* - Otherwise use the provided `shell` (string | boolean).
|
|
1552
|
+
*
|
|
1553
|
+
* @param scripts - Optional scripts table.
|
|
1554
|
+
* @param command - User-provided command name or string.
|
|
1555
|
+
* @param shell - Global shell preference (string | boolean).
|
|
1556
|
+
*/
|
|
1557
|
+
const resolveShell = (scripts, command, shell) => scripts && typeof scripts[command] === 'object'
|
|
1558
|
+
? (scripts[command].shell ?? false)
|
|
1559
|
+
: (shell ?? false);
|
|
1560
|
+
|
|
1561
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
1562
|
+
const trim = (s) => (typeof s === 'string' ? s.trim() : '');
|
|
1563
|
+
const unquote = (s) => s.length >= 2 &&
|
|
1564
|
+
((s.startsWith('"') && s.endsWith('"')) ||
|
|
1565
|
+
(s.startsWith("'") && s.endsWith("'")))
|
|
1566
|
+
? s.slice(1, -1)
|
|
1567
|
+
: s;
|
|
1568
|
+
const parseExportCredentialsJson = (txt) => {
|
|
605
1569
|
try {
|
|
606
|
-
|
|
1570
|
+
const obj = JSON.parse(txt);
|
|
1571
|
+
const src = obj.Credentials ?? obj;
|
|
1572
|
+
const ak = src.AccessKeyId;
|
|
1573
|
+
const sk = src.SecretAccessKey;
|
|
1574
|
+
const tk = src.SessionToken;
|
|
1575
|
+
if (ak && sk)
|
|
1576
|
+
return {
|
|
1577
|
+
accessKeyId: ak,
|
|
1578
|
+
secretAccessKey: sk,
|
|
1579
|
+
...(tk ? { sessionToken: tk } : {}),
|
|
1580
|
+
};
|
|
607
1581
|
}
|
|
608
1582
|
catch {
|
|
609
|
-
|
|
1583
|
+
/* ignore */
|
|
610
1584
|
}
|
|
1585
|
+
return undefined;
|
|
611
1586
|
};
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
1587
|
+
const parseExportCredentialsEnv = (txt) => {
|
|
1588
|
+
const lines = txt.split(/\r?\n/);
|
|
1589
|
+
let id;
|
|
1590
|
+
let secret;
|
|
1591
|
+
let token;
|
|
1592
|
+
for (const raw of lines) {
|
|
1593
|
+
const line = raw.trim();
|
|
1594
|
+
if (!line)
|
|
1595
|
+
continue;
|
|
1596
|
+
// POSIX: export AWS_ACCESS_KEY_ID=..., export AWS_SECRET_ACCESS_KEY=..., export AWS_SESSION_TOKEN=...
|
|
1597
|
+
let m = /^export\s+([A-Z0-9_]+)\s*=\s*(.+)$/.exec(line);
|
|
1598
|
+
if (!m) {
|
|
1599
|
+
// PowerShell: $Env:AWS_ACCESS_KEY_ID="...", etc.
|
|
1600
|
+
m = /^\$Env:([A-Z0-9_]+)\s*=\s*(.+)$/.exec(line);
|
|
1601
|
+
}
|
|
1602
|
+
if (!m)
|
|
1603
|
+
continue;
|
|
1604
|
+
const k = m[1];
|
|
1605
|
+
const valRaw = m[2];
|
|
1606
|
+
if (typeof valRaw !== 'string')
|
|
1607
|
+
continue;
|
|
1608
|
+
let v = unquote(valRaw.trim());
|
|
1609
|
+
// Drop trailing semicolons if present (some shells)
|
|
1610
|
+
v = v.replace(/;$/, '');
|
|
1611
|
+
if (k === 'AWS_ACCESS_KEY_ID')
|
|
1612
|
+
id = v;
|
|
1613
|
+
else if (k === 'AWS_SECRET_ACCESS_KEY')
|
|
1614
|
+
secret = v;
|
|
1615
|
+
else if (k === 'AWS_SESSION_TOKEN')
|
|
1616
|
+
token = v;
|
|
1617
|
+
}
|
|
1618
|
+
if (id && secret)
|
|
1619
|
+
return {
|
|
1620
|
+
accessKeyId: id,
|
|
1621
|
+
secretAccessKey: secret,
|
|
1622
|
+
...(token ? { sessionToken: token } : {}),
|
|
1623
|
+
};
|
|
1624
|
+
return undefined;
|
|
616
1625
|
};
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
.
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* @internal Load a dynamic module from path. Supports .js/.mjs/.ts/.tsx:
|
|
627
|
-
* - .js/.mjs: direct import
|
|
628
|
-
* - .ts/.tsx: try direct import (in case a TS loader is active), otherwise:
|
|
629
|
-
* - esbuild (if present): bundle to a temp ESM file and import it
|
|
630
|
-
* - fallback: typescript.transpileModule (single-file), then import temp file
|
|
631
|
-
*/
|
|
632
|
-
const loadDynamicFromPath = async (absPath) => {
|
|
633
|
-
if (!(await fs.exists(absPath)))
|
|
1626
|
+
const getAwsConfigure = async (key, profile, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
1627
|
+
const r = await runCommandResult(['aws', 'configure', 'get', key, '--profile', profile], false, {
|
|
1628
|
+
env: process.env,
|
|
1629
|
+
timeoutMs,
|
|
1630
|
+
});
|
|
1631
|
+
// Guard for mocked undefined in tests; keep narrow lint scope.
|
|
1632
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
1633
|
+
if (!r || typeof r.exitCode !== 'number')
|
|
634
1634
|
return undefined;
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
return importDefault(fileUrl);
|
|
1635
|
+
if (r.exitCode === 0) {
|
|
1636
|
+
const v = trim(r.stdout);
|
|
1637
|
+
return v.length > 0 ? v : undefined;
|
|
639
1638
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1639
|
+
return undefined;
|
|
1640
|
+
};
|
|
1641
|
+
const exportCredentials = async (profile, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
1642
|
+
// Try JSON format first (AWS CLI v2)
|
|
1643
|
+
const rJson = await runCommandResult([
|
|
1644
|
+
'aws',
|
|
1645
|
+
'configure',
|
|
1646
|
+
'export-credentials',
|
|
1647
|
+
'--profile',
|
|
1648
|
+
profile,
|
|
1649
|
+
'--format',
|
|
1650
|
+
'json',
|
|
1651
|
+
], false, { env: process.env, timeoutMs });
|
|
1652
|
+
if (rJson.exitCode === 0) {
|
|
1653
|
+
const creds = parseExportCredentialsJson(rJson.stdout);
|
|
1654
|
+
if (creds)
|
|
1655
|
+
return creds;
|
|
645
1656
|
}
|
|
646
|
-
|
|
647
|
-
|
|
1657
|
+
// Fallback: env lines
|
|
1658
|
+
const rEnv = await runCommandResult(['aws', 'configure', 'export-credentials', '--profile', profile], false, { env: process.env, timeoutMs });
|
|
1659
|
+
if (rEnv.exitCode === 0) {
|
|
1660
|
+
const creds = parseExportCredentialsEnv(rEnv.stdout);
|
|
1661
|
+
if (creds)
|
|
1662
|
+
return creds;
|
|
648
1663
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1664
|
+
return undefined;
|
|
1665
|
+
};
|
|
1666
|
+
const resolveAwsContext = async ({ dotenv, cfg, }) => {
|
|
1667
|
+
const profileKey = cfg.profileKey ?? 'AWS_LOCAL_PROFILE';
|
|
1668
|
+
const profileFallbackKey = cfg.profileFallbackKey ?? 'AWS_PROFILE';
|
|
1669
|
+
const regionKey = cfg.regionKey ?? 'AWS_REGION';
|
|
1670
|
+
const profile = cfg.profile ??
|
|
1671
|
+
dotenv[profileKey] ??
|
|
1672
|
+
dotenv[profileFallbackKey] ??
|
|
1673
|
+
undefined;
|
|
1674
|
+
let region = cfg.region ?? dotenv[regionKey] ?? undefined;
|
|
1675
|
+
// Short-circuit when strategy is disabled.
|
|
1676
|
+
if (cfg.strategy === 'none') {
|
|
1677
|
+
// If region is still missing and we have a profile, try best-effort region resolve.
|
|
1678
|
+
if (!region && profile)
|
|
1679
|
+
region = await getAwsConfigure('region', profile);
|
|
1680
|
+
if (!region && cfg.defaultRegion)
|
|
1681
|
+
region = cfg.defaultRegion;
|
|
1682
|
+
const out = {};
|
|
1683
|
+
if (profile !== undefined)
|
|
1684
|
+
out.profile = profile;
|
|
1685
|
+
if (region !== undefined)
|
|
1686
|
+
out.region = region;
|
|
1687
|
+
return out;
|
|
1688
|
+
}
|
|
1689
|
+
// Env-first credentials.
|
|
1690
|
+
let credentials;
|
|
1691
|
+
const envId = trim(process.env.AWS_ACCESS_KEY_ID);
|
|
1692
|
+
const envSecret = trim(process.env.AWS_SECRET_ACCESS_KEY);
|
|
1693
|
+
const envToken = trim(process.env.AWS_SESSION_TOKEN);
|
|
1694
|
+
if (envId && envSecret) {
|
|
1695
|
+
credentials = {
|
|
1696
|
+
accessKeyId: envId,
|
|
1697
|
+
secretAccessKey: envSecret,
|
|
1698
|
+
...(envToken ? { sessionToken: envToken } : {}),
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
else if (profile) {
|
|
1702
|
+
// Try export-credentials
|
|
1703
|
+
credentials = await exportCredentials(profile);
|
|
1704
|
+
// On failure, detect SSO and optionally login then retry
|
|
1705
|
+
if (!credentials) {
|
|
1706
|
+
const ssoSession = await getAwsConfigure('sso_session', profile);
|
|
1707
|
+
const looksSSO = typeof ssoSession === 'string' && ssoSession.length > 0;
|
|
1708
|
+
if (looksSSO && cfg.loginOnDemand) {
|
|
1709
|
+
// Best-effort login, then retry export once.
|
|
1710
|
+
await runCommandResult(['aws', 'sso', 'login', '--profile', profile], false, {
|
|
1711
|
+
env: process.env,
|
|
1712
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
1713
|
+
});
|
|
1714
|
+
credentials = await exportCredentials(profile);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
// Static fallback if still missing.
|
|
1718
|
+
if (!credentials) {
|
|
1719
|
+
const id = await getAwsConfigure('aws_access_key_id', profile);
|
|
1720
|
+
const secret = await getAwsConfigure('aws_secret_access_key', profile);
|
|
1721
|
+
const token = await getAwsConfigure('aws_session_token', profile);
|
|
1722
|
+
if (id && secret) {
|
|
1723
|
+
credentials = {
|
|
1724
|
+
accessKeyId: id,
|
|
1725
|
+
secretAccessKey: secret,
|
|
1726
|
+
...(token ? { sessionToken: token } : {}),
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
// Final region resolution
|
|
1732
|
+
if (!region && profile)
|
|
1733
|
+
region = await getAwsConfigure('region', profile);
|
|
1734
|
+
if (!region && cfg.defaultRegion)
|
|
1735
|
+
region = cfg.defaultRegion;
|
|
1736
|
+
const out = {};
|
|
1737
|
+
if (profile !== undefined)
|
|
1738
|
+
out.profile = profile;
|
|
1739
|
+
if (region !== undefined)
|
|
1740
|
+
out.region = region;
|
|
1741
|
+
if (credentials)
|
|
1742
|
+
out.credentials = credentials;
|
|
1743
|
+
return out;
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
const AwsPluginConfigSchema = z.object({
|
|
1747
|
+
profile: z.string().optional(),
|
|
1748
|
+
region: z.string().optional(),
|
|
1749
|
+
defaultRegion: z.string().optional(),
|
|
1750
|
+
profileKey: z.string().default('AWS_LOCAL_PROFILE').optional(),
|
|
1751
|
+
profileFallbackKey: z.string().default('AWS_PROFILE').optional(),
|
|
1752
|
+
regionKey: z.string().default('AWS_REGION').optional(),
|
|
1753
|
+
strategy: z.enum(['cli-export', 'none']).default('cli-export').optional(),
|
|
1754
|
+
loginOnDemand: z.boolean().default(false).optional(),
|
|
1755
|
+
setEnv: z.boolean().default(true).optional(),
|
|
1756
|
+
addCtx: z.boolean().default(true).optional(),
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
const awsPlugin = () => definePlugin({
|
|
1760
|
+
id: 'aws',
|
|
1761
|
+
// Host validates this slice when the loader path is active.
|
|
1762
|
+
configSchema: AwsPluginConfigSchema,
|
|
1763
|
+
setup(cli) {
|
|
1764
|
+
// Subcommand: aws
|
|
1765
|
+
cli
|
|
1766
|
+
.ns('aws')
|
|
1767
|
+
.description('Establish an AWS session and optionally forward to the AWS CLI')
|
|
1768
|
+
.configureHelp({ showGlobalOptions: true })
|
|
1769
|
+
.enablePositionalOptions()
|
|
1770
|
+
.passThroughOptions()
|
|
1771
|
+
.allowUnknownOption(true)
|
|
1772
|
+
// Boolean toggles
|
|
1773
|
+
.option('--login-on-demand', 'attempt aws sso login on-demand')
|
|
1774
|
+
.option('--no-login-on-demand', 'disable sso login on-demand')
|
|
1775
|
+
.option('--set-env', 'write resolved values into process.env')
|
|
1776
|
+
.option('--no-set-env', 'do not write resolved values into process.env')
|
|
1777
|
+
.option('--add-ctx', 'mirror results under ctx.plugins.aws')
|
|
1778
|
+
.option('--no-add-ctx', 'do not mirror results under ctx.plugins.aws')
|
|
1779
|
+
// Strings / enums
|
|
1780
|
+
.option('--profile <string>', 'AWS profile name')
|
|
1781
|
+
.option('--region <string>', 'AWS region')
|
|
1782
|
+
.option('--default-region <string>', 'fallback region')
|
|
1783
|
+
.option('--strategy <string>', 'credential acquisition strategy: cli-export|none')
|
|
1784
|
+
// Advanced key overrides
|
|
1785
|
+
.option('--profile-key <string>', 'dotenv/config key for local profile')
|
|
1786
|
+
.option('--profile-fallback-key <string>', 'fallback dotenv/config key for profile')
|
|
1787
|
+
.option('--region-key <string>', 'dotenv/config key for region')
|
|
1788
|
+
// Accept any extra operands so Commander does not error when tokens appear after "--".
|
|
1789
|
+
.argument('[args...]')
|
|
1790
|
+
.action(async (args, opts, thisCommand) => {
|
|
1791
|
+
const self = thisCommand;
|
|
1792
|
+
const parent = (self.parent ?? null);
|
|
1793
|
+
// Access merged root CLI options (installed by passOptions())
|
|
1794
|
+
const rootOpts = (parent?.getDotenvCliOptions ?? {});
|
|
1795
|
+
const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
|
|
1796
|
+
Boolean(rootOpts?.capture);
|
|
1797
|
+
const underTests = process.env.GETDOTENV_TEST === '1' ||
|
|
1798
|
+
typeof process.env.VITEST_WORKER_ID === 'string';
|
|
1799
|
+
// Build overlay cfg from subcommand flags layered over discovered config.
|
|
1800
|
+
const ctx = cli.getCtx();
|
|
1801
|
+
const cfgBase = (ctx?.pluginConfigs?.['aws'] ??
|
|
1802
|
+
{});
|
|
1803
|
+
const overlay = {};
|
|
1804
|
+
// Map boolean toggles (respect explicit --no-*)
|
|
1805
|
+
if (Object.prototype.hasOwnProperty.call(opts, 'loginOnDemand'))
|
|
1806
|
+
overlay.loginOnDemand = Boolean(opts.loginOnDemand);
|
|
1807
|
+
if (Object.prototype.hasOwnProperty.call(opts, 'setEnv'))
|
|
1808
|
+
overlay.setEnv = Boolean(opts.setEnv);
|
|
1809
|
+
if (Object.prototype.hasOwnProperty.call(opts, 'addCtx'))
|
|
1810
|
+
overlay.addCtx = Boolean(opts.addCtx);
|
|
1811
|
+
// Strings/enums
|
|
1812
|
+
if (typeof opts.profile === 'string')
|
|
1813
|
+
overlay.profile = opts.profile;
|
|
1814
|
+
if (typeof opts.region === 'string')
|
|
1815
|
+
overlay.region = opts.region;
|
|
1816
|
+
if (typeof opts.defaultRegion === 'string')
|
|
1817
|
+
overlay.defaultRegion = opts.defaultRegion;
|
|
1818
|
+
if (typeof opts.strategy === 'string')
|
|
1819
|
+
overlay.strategy =
|
|
1820
|
+
opts.strategy;
|
|
1821
|
+
// Advanced key overrides
|
|
1822
|
+
if (typeof opts.profileKey === 'string')
|
|
1823
|
+
overlay.profileKey = opts.profileKey;
|
|
1824
|
+
if (typeof opts.profileFallbackKey === 'string')
|
|
1825
|
+
overlay.profileFallbackKey = opts.profileFallbackKey;
|
|
1826
|
+
if (typeof opts.regionKey === 'string')
|
|
1827
|
+
overlay.regionKey = opts.regionKey;
|
|
1828
|
+
const cfg = {
|
|
1829
|
+
...cfgBase,
|
|
1830
|
+
...overlay,
|
|
1831
|
+
};
|
|
1832
|
+
// Resolve current context with overrides
|
|
1833
|
+
const out = await resolveAwsContext({
|
|
1834
|
+
dotenv: ctx?.dotenv ?? {},
|
|
1835
|
+
cfg,
|
|
1836
|
+
});
|
|
1837
|
+
// Apply env/ctx mirrors per toggles
|
|
1838
|
+
if (cfg.setEnv !== false) {
|
|
1839
|
+
if (out.region) {
|
|
1840
|
+
process.env.AWS_REGION = out.region;
|
|
1841
|
+
if (!process.env.AWS_DEFAULT_REGION)
|
|
1842
|
+
process.env.AWS_DEFAULT_REGION = out.region;
|
|
1843
|
+
}
|
|
1844
|
+
if (out.credentials) {
|
|
1845
|
+
process.env.AWS_ACCESS_KEY_ID = out.credentials.accessKeyId;
|
|
1846
|
+
process.env.AWS_SECRET_ACCESS_KEY =
|
|
1847
|
+
out.credentials.secretAccessKey;
|
|
1848
|
+
if (out.credentials.sessionToken !== undefined) {
|
|
1849
|
+
process.env.AWS_SESSION_TOKEN = out.credentials.sessionToken;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (cfg.addCtx !== false) {
|
|
1854
|
+
if (ctx) {
|
|
1855
|
+
ctx.plugins ??= {};
|
|
1856
|
+
ctx.plugins['aws'] = {
|
|
1857
|
+
...(out.profile ? { profile: out.profile } : {}),
|
|
1858
|
+
...(out.region ? { region: out.region } : {}),
|
|
1859
|
+
...(out.credentials ? { credentials: out.credentials } : {}),
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
// Forward when positional args are present; otherwise session-only.
|
|
1864
|
+
if (Array.isArray(args) && args.length > 0) {
|
|
1865
|
+
const argv = ['aws', ...args];
|
|
1866
|
+
const shellSetting = resolveShell(rootOpts?.scripts, 'aws', rootOpts?.shell);
|
|
1867
|
+
const ctxDotenv = (ctx?.dotenv ?? {});
|
|
1868
|
+
const exit = await runCommand(argv, shellSetting, {
|
|
1869
|
+
env: { ...process.env, ...ctxDotenv },
|
|
1870
|
+
stdio: capture ? 'pipe' : 'inherit',
|
|
1871
|
+
});
|
|
1872
|
+
// Deterministic termination (suppressed under tests)
|
|
1873
|
+
if (!underTests) {
|
|
1874
|
+
process.exit(typeof exit === 'number' ? exit : 0);
|
|
1875
|
+
}
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
// Session only: low-noise breadcrumb under debug
|
|
1880
|
+
if (process.env.GETDOTENV_DEBUG) {
|
|
1881
|
+
const log = console;
|
|
1882
|
+
log.log('[aws] session established', {
|
|
1883
|
+
profile: out.profile,
|
|
1884
|
+
region: out.region,
|
|
1885
|
+
hasCreds: Boolean(out.credentials),
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
if (!underTests)
|
|
1889
|
+
process.exit(0);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
},
|
|
1894
|
+
async afterResolve(_cli, ctx) {
|
|
1895
|
+
const log = console;
|
|
1896
|
+
const cfgRaw = (ctx.pluginConfigs?.['aws'] ?? {});
|
|
1897
|
+
const cfg = (cfgRaw || {});
|
|
1898
|
+
const out = await resolveAwsContext({
|
|
1899
|
+
dotenv: ctx.dotenv,
|
|
1900
|
+
cfg,
|
|
666
1901
|
});
|
|
667
|
-
|
|
1902
|
+
const { profile, region, credentials } = out;
|
|
1903
|
+
if (cfg.setEnv !== false) {
|
|
1904
|
+
if (region) {
|
|
1905
|
+
process.env.AWS_REGION = region;
|
|
1906
|
+
if (!process.env.AWS_DEFAULT_REGION)
|
|
1907
|
+
process.env.AWS_DEFAULT_REGION = region;
|
|
1908
|
+
}
|
|
1909
|
+
if (credentials) {
|
|
1910
|
+
process.env.AWS_ACCESS_KEY_ID = credentials.accessKeyId;
|
|
1911
|
+
process.env.AWS_SECRET_ACCESS_KEY = credentials.secretAccessKey;
|
|
1912
|
+
if (credentials.sessionToken !== undefined) {
|
|
1913
|
+
process.env.AWS_SESSION_TOKEN = credentials.sessionToken;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (cfg.addCtx !== false) {
|
|
1918
|
+
ctx.plugins ??= {};
|
|
1919
|
+
ctx.plugins['aws'] = {
|
|
1920
|
+
...(profile ? { profile } : {}),
|
|
1921
|
+
...(region ? { region } : {}),
|
|
1922
|
+
...(credentials ? { credentials } : {}),
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
// Optional: low-noise breadcrumb for diagnostics
|
|
1926
|
+
if (process.env.GETDOTENV_DEBUG) {
|
|
1927
|
+
log.log('[aws] afterResolve', {
|
|
1928
|
+
profile,
|
|
1929
|
+
region,
|
|
1930
|
+
hasCreds: Boolean(credentials),
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
},
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
const globPaths = async ({ globs, logger, pkgCwd, rootPath, }) => {
|
|
1937
|
+
let cwd = process.cwd();
|
|
1938
|
+
if (pkgCwd) {
|
|
1939
|
+
const pkgDir = await packageDirectory();
|
|
1940
|
+
if (!pkgDir) {
|
|
1941
|
+
logger.error('No package directory found.');
|
|
1942
|
+
process.exit(0);
|
|
1943
|
+
}
|
|
1944
|
+
cwd = pkgDir;
|
|
668
1945
|
}
|
|
669
|
-
|
|
670
|
-
|
|
1946
|
+
const absRootPath = path.posix.join(cwd.split(path.sep).join(path.posix.sep), rootPath.split(path.sep).join(path.posix.sep));
|
|
1947
|
+
const paths = await globby(globs.split(/\s+/), {
|
|
1948
|
+
cwd: absRootPath,
|
|
1949
|
+
expandDirectories: false,
|
|
1950
|
+
onlyDirectories: true,
|
|
1951
|
+
absolute: true,
|
|
1952
|
+
});
|
|
1953
|
+
if (!paths.length) {
|
|
1954
|
+
logger.error(`No paths found for globs '${globs}' at '${absRootPath}'.`);
|
|
1955
|
+
process.exit(0);
|
|
671
1956
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
1957
|
+
return { absRootPath, paths };
|
|
1958
|
+
};
|
|
1959
|
+
const execShellCommandBatch = async ({ command, getDotenvCliOptions, globs, ignoreErrors, list, logger, pkgCwd, rootPath, shell, }) => {
|
|
1960
|
+
const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
|
|
1961
|
+
Boolean(getDotenvCliOptions?.capture); // Require a command only when not listing. In list mode, a command is optional.
|
|
1962
|
+
if (!command && !list) {
|
|
1963
|
+
logger.error(`No command provided. Use --command or --list.`);
|
|
1964
|
+
process.exit(0);
|
|
1965
|
+
}
|
|
1966
|
+
const { absRootPath, paths } = await globPaths({
|
|
1967
|
+
globs,
|
|
1968
|
+
logger,
|
|
1969
|
+
rootPath,
|
|
1970
|
+
// exactOptionalPropertyTypes: only include when defined
|
|
1971
|
+
...(pkgCwd !== undefined ? { pkgCwd } : {}),
|
|
1972
|
+
});
|
|
1973
|
+
const headerTitle = list
|
|
1974
|
+
? 'Listing working directories...'
|
|
1975
|
+
: 'Executing command batch...';
|
|
1976
|
+
logger.info('');
|
|
1977
|
+
const headerRootPath = `ROOT: ${absRootPath}`;
|
|
1978
|
+
const headerGlobs = `GLOBS: ${globs}`;
|
|
1979
|
+
// Prepare a safe label for the header (avoid undefined in template)
|
|
1980
|
+
const commandLabel = Array.isArray(command)
|
|
1981
|
+
? command.join(' ')
|
|
1982
|
+
: typeof command === 'string' && command.length > 0
|
|
1983
|
+
? command
|
|
1984
|
+
: '';
|
|
1985
|
+
const headerCommand = list ? `CMD: (list only)` : `CMD: ${commandLabel}`;
|
|
1986
|
+
logger.info('*'.repeat(Math.max(headerTitle.length, headerRootPath.length, headerGlobs.length, headerCommand.length)));
|
|
1987
|
+
logger.info(headerTitle);
|
|
1988
|
+
logger.info('');
|
|
1989
|
+
logger.info(headerRootPath);
|
|
1990
|
+
logger.info(headerGlobs);
|
|
1991
|
+
logger.info(headerCommand);
|
|
1992
|
+
for (const path of paths) {
|
|
1993
|
+
// Write path and command to console.
|
|
1994
|
+
const pathLabel = `CWD: ${path}`;
|
|
1995
|
+
if (list) {
|
|
1996
|
+
logger.info(pathLabel);
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
logger.info('');
|
|
2000
|
+
logger.info('*'.repeat(pathLabel.length));
|
|
2001
|
+
logger.info(pathLabel);
|
|
2002
|
+
logger.info(headerCommand);
|
|
2003
|
+
// Execute command.
|
|
2004
|
+
try {
|
|
2005
|
+
const hasCmd = (typeof command === 'string' && command.length > 0) ||
|
|
2006
|
+
(Array.isArray(command) && command.length > 0);
|
|
2007
|
+
if (hasCmd) {
|
|
2008
|
+
await runCommand(command, shell, {
|
|
2009
|
+
cwd: path,
|
|
2010
|
+
env: {
|
|
2011
|
+
...process.env,
|
|
2012
|
+
getDotenvCliOptions: getDotenvCliOptions
|
|
2013
|
+
? JSON.stringify(getDotenvCliOptions)
|
|
2014
|
+
: undefined,
|
|
2015
|
+
},
|
|
2016
|
+
stdio: capture ? 'pipe' : 'inherit',
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
else {
|
|
2020
|
+
// Should not occur due to the early guard; retain for type safety.
|
|
2021
|
+
logger.error(`No command provided. Use --command or --list.`);
|
|
2022
|
+
process.exit(0);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
catch (error) {
|
|
2026
|
+
if (!ignoreErrors) {
|
|
2027
|
+
throw error;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
logger.info('');
|
|
2032
|
+
};
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Build the default "cmd" subcommand action for the batch plugin.
|
|
2036
|
+
* Mirrors the original inline implementation with identical behavior.
|
|
2037
|
+
*/
|
|
2038
|
+
const buildDefaultCmdAction = (cli, batchCmd, opts) => async (commandParts, _subOpts, _thisCommand) => {
|
|
2039
|
+
const loggerLocal = opts.logger ?? console;
|
|
2040
|
+
// Guard: when invoked without positional args (e.g., `batch --list`),
|
|
2041
|
+
// defer entirely to the parent action handler.
|
|
2042
|
+
const argsRaw = Array.isArray(commandParts)
|
|
2043
|
+
? commandParts
|
|
2044
|
+
: [];
|
|
2045
|
+
const localList = argsRaw.includes('-l') || argsRaw.includes('--list');
|
|
2046
|
+
const args = localList
|
|
2047
|
+
? argsRaw.filter((t) => t !== '-l' && t !== '--list')
|
|
2048
|
+
: argsRaw;
|
|
2049
|
+
// Access merged per-plugin config from host context (if any).
|
|
2050
|
+
const ctx = cli.getCtx();
|
|
2051
|
+
const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
|
|
2052
|
+
const cfg = (cfgRaw || {});
|
|
2053
|
+
// Resolve batch flags from the captured parent (batch) command.
|
|
2054
|
+
const raw = batchCmd.opts();
|
|
2055
|
+
const listFromParent = !!raw.list;
|
|
2056
|
+
const ignoreErrors = !!raw.ignoreErrors;
|
|
2057
|
+
const globs = typeof raw.globs === 'string' ? raw.globs : (cfg.globs ?? '*');
|
|
2058
|
+
const pkgCwd = raw.pkgCwd !== undefined ? !!raw.pkgCwd : !!cfg.pkgCwd;
|
|
2059
|
+
const rootPath = typeof raw.rootPath === 'string' ? raw.rootPath : (cfg.rootPath ?? './');
|
|
2060
|
+
// Resolve scripts/shell with precedence:
|
|
2061
|
+
// plugin opts → plugin config → merged root CLI options
|
|
2062
|
+
const mergedBag = ((batchCmd.parent ?? null)?.getDotenvCliOptions ?? {});
|
|
2063
|
+
const scripts = opts.scripts ?? cfg.scripts ?? mergedBag.scripts;
|
|
2064
|
+
const shell = opts.shell ?? cfg.shell ?? mergedBag.shell;
|
|
2065
|
+
// If no positional args were given, bridge to --command/--list paths here.
|
|
2066
|
+
if (args.length === 0) {
|
|
2067
|
+
const commandOpt = typeof raw.command === 'string' ? raw.command : undefined;
|
|
2068
|
+
if (typeof commandOpt === 'string') {
|
|
2069
|
+
await execShellCommandBatch({
|
|
2070
|
+
command: resolveCommand(scripts, commandOpt),
|
|
2071
|
+
globs,
|
|
2072
|
+
ignoreErrors,
|
|
2073
|
+
list: false,
|
|
2074
|
+
logger: loggerLocal,
|
|
2075
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2076
|
+
rootPath,
|
|
2077
|
+
shell: resolveShell(scripts, commandOpt, shell),
|
|
2078
|
+
});
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (raw.list || localList) {
|
|
2082
|
+
await execShellCommandBatch({
|
|
2083
|
+
globs,
|
|
2084
|
+
ignoreErrors,
|
|
2085
|
+
list: true,
|
|
2086
|
+
logger: loggerLocal,
|
|
2087
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2088
|
+
rootPath,
|
|
2089
|
+
shell: (shell ?? false),
|
|
2090
|
+
});
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
{
|
|
2094
|
+
const lr = loggerLocal;
|
|
2095
|
+
const emit = lr.error ?? lr.log;
|
|
2096
|
+
emit(`No command provided. Use --command or --list.`);
|
|
2097
|
+
}
|
|
2098
|
+
process.exit(0);
|
|
2099
|
+
}
|
|
2100
|
+
// If a local list flag was supplied with positional tokens (and no --command),
|
|
2101
|
+
// treat tokens as additional globs and execute list mode.
|
|
2102
|
+
if (localList && typeof raw.command !== 'string') {
|
|
2103
|
+
const extraGlobs = args.map(String).join(' ').trim();
|
|
2104
|
+
const mergedGlobs = [globs, extraGlobs].filter(Boolean).join(' ');
|
|
2105
|
+
const shellBag = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
|
|
2106
|
+
await execShellCommandBatch({
|
|
2107
|
+
globs: mergedGlobs,
|
|
2108
|
+
ignoreErrors,
|
|
2109
|
+
list: true,
|
|
2110
|
+
logger: loggerLocal,
|
|
2111
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2112
|
+
rootPath,
|
|
2113
|
+
shell: (shell ?? shellBag.shell ?? false),
|
|
681
2114
|
});
|
|
682
|
-
|
|
683
|
-
await fs.writeFile(cacheFile, out.outputText, 'utf-8');
|
|
684
|
-
return await importDefault(url.pathToFileURL(cacheFile).toString());
|
|
2115
|
+
return;
|
|
685
2116
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
2117
|
+
// If parent list flag is set and positional tokens are present (and no --command),
|
|
2118
|
+
// treat tokens as additional globs for list-only mode.
|
|
2119
|
+
if (listFromParent && args.length > 0 && typeof raw.command !== 'string') {
|
|
2120
|
+
const extra = args.map(String).join(' ').trim();
|
|
2121
|
+
const mergedGlobs = [globs, extra].filter(Boolean).join(' ');
|
|
2122
|
+
const mergedBag2 = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
|
|
2123
|
+
await execShellCommandBatch({
|
|
2124
|
+
globs: mergedGlobs,
|
|
2125
|
+
ignoreErrors,
|
|
2126
|
+
list: true,
|
|
2127
|
+
logger: loggerLocal,
|
|
2128
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2129
|
+
rootPath,
|
|
2130
|
+
shell: (shell ?? mergedBag2.shell ?? false),
|
|
2131
|
+
});
|
|
2132
|
+
return;
|
|
689
2133
|
}
|
|
2134
|
+
// Join positional args as the command to execute.
|
|
2135
|
+
const input = args.map(String).join(' ');
|
|
2136
|
+
// Optional: round-trip parent merged options if present (shipped CLI).
|
|
2137
|
+
const envBag = (batchCmd.parent ?? undefined)?.getDotenvCliOptions;
|
|
2138
|
+
const mergedExec = ((batchCmd.parent ?? undefined)?.getDotenvCliOptions ?? {});
|
|
2139
|
+
const scriptsExec = scripts ?? mergedExec.scripts;
|
|
2140
|
+
const shellExec = shell ?? mergedExec.shell;
|
|
2141
|
+
const resolved = resolveCommand(scriptsExec, input);
|
|
2142
|
+
const shellSetting = resolveShell(scriptsExec, input, shellExec);
|
|
2143
|
+
// Preserve argv array only for shell-off Node -e snippets to avoid
|
|
2144
|
+
// lossy re-tokenization (Windows/PowerShell quoting). For simple
|
|
2145
|
+
// commands (e.g., "echo OK") keep string form to satisfy unit tests.
|
|
2146
|
+
let commandArg = resolved;
|
|
2147
|
+
if (shellSetting === false && resolved === input) {
|
|
2148
|
+
const first = (args[0] ?? '').toLowerCase();
|
|
2149
|
+
const hasEval = args.includes('-e') || args.includes('--eval');
|
|
2150
|
+
if (first === 'node' && hasEval) {
|
|
2151
|
+
commandArg = args.map(String);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
await execShellCommandBatch({
|
|
2155
|
+
command: commandArg,
|
|
2156
|
+
...(envBag ? { getDotenvCliOptions: envBag } : {}),
|
|
2157
|
+
globs,
|
|
2158
|
+
ignoreErrors,
|
|
2159
|
+
list: false,
|
|
2160
|
+
logger: loggerLocal,
|
|
2161
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2162
|
+
rootPath,
|
|
2163
|
+
shell: shellSetting,
|
|
2164
|
+
});
|
|
690
2165
|
};
|
|
2166
|
+
|
|
691
2167
|
/**
|
|
692
|
-
*
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
*
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
2168
|
+
* Build the parent "batch" action handler (no explicit subcommand).
|
|
2169
|
+
*/
|
|
2170
|
+
const buildParentAction = (cli, opts) => async (commandParts, thisCommand) => {
|
|
2171
|
+
const logger = opts.logger ?? console;
|
|
2172
|
+
// Ensure context exists (host preSubcommand on root creates if missing).
|
|
2173
|
+
const ctx = cli.getCtx();
|
|
2174
|
+
const cfgRaw = (ctx?.pluginConfigs?.['batch'] ?? {});
|
|
2175
|
+
const cfg = (cfgRaw || {});
|
|
2176
|
+
const raw = thisCommand.opts();
|
|
2177
|
+
const commandOpt = typeof raw.command === 'string' ? raw.command : undefined;
|
|
2178
|
+
const ignoreErrors = !!raw.ignoreErrors;
|
|
2179
|
+
let globs = typeof raw.globs === 'string' ? raw.globs : (cfg.globs ?? '*');
|
|
2180
|
+
const list = !!raw.list;
|
|
2181
|
+
const pkgCwd = raw.pkgCwd !== undefined ? !!raw.pkgCwd : !!cfg.pkgCwd;
|
|
2182
|
+
const rootPath = typeof raw.rootPath === 'string' ? raw.rootPath : (cfg.rootPath ?? './');
|
|
2183
|
+
// Treat parent positional tokens as the command when no explicit 'cmd' is used.
|
|
2184
|
+
const argsParent = Array.isArray(commandParts) ? commandParts : [];
|
|
2185
|
+
if (argsParent.length > 0 && !list) {
|
|
2186
|
+
const input = argsParent.map(String).join(' ');
|
|
2187
|
+
const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
|
|
2188
|
+
const scriptsAll = opts.scripts ?? cfg.scripts ?? mergedBag.scripts;
|
|
2189
|
+
const shellAll = opts.shell ?? cfg.shell ?? mergedBag.shell;
|
|
2190
|
+
const resolved = resolveCommand(scriptsAll, input);
|
|
2191
|
+
const shellSetting = resolveShell(scriptsAll, input, shellAll);
|
|
2192
|
+
// Parent path: pass a string; executor handles shell-specific details.
|
|
2193
|
+
const commandArg = resolved;
|
|
2194
|
+
await execShellCommandBatch({
|
|
2195
|
+
command: commandArg,
|
|
2196
|
+
globs,
|
|
2197
|
+
ignoreErrors,
|
|
2198
|
+
list: false,
|
|
2199
|
+
logger,
|
|
2200
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2201
|
+
rootPath,
|
|
2202
|
+
shell: shellSetting,
|
|
2203
|
+
});
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
// List-only: merge extra positional tokens into globs when no --command is present.
|
|
2207
|
+
if (list && argsParent.length > 0 && !commandOpt) {
|
|
2208
|
+
const extra = argsParent.map(String).join(' ').trim();
|
|
2209
|
+
if (extra.length > 0)
|
|
2210
|
+
globs = [globs, extra].filter(Boolean).join(' ');
|
|
2211
|
+
const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
|
|
2212
|
+
await execShellCommandBatch({
|
|
2213
|
+
globs,
|
|
2214
|
+
ignoreErrors,
|
|
2215
|
+
list: true,
|
|
2216
|
+
logger,
|
|
2217
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2218
|
+
rootPath,
|
|
2219
|
+
shell: (opts.shell ?? cfg.shell ?? mergedBag.shell ?? false),
|
|
2220
|
+
});
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (!commandOpt && !list) {
|
|
2224
|
+
logger.error(`No command provided. Use --command or --list.`);
|
|
2225
|
+
process.exit(0);
|
|
2226
|
+
}
|
|
2227
|
+
if (typeof commandOpt === 'string') {
|
|
2228
|
+
const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
|
|
2229
|
+
const scriptsOpt = opts.scripts ?? cfg.scripts ?? mergedBag.scripts;
|
|
2230
|
+
const shellOpt = opts.shell ?? cfg.shell ?? mergedBag.shell;
|
|
2231
|
+
await execShellCommandBatch({
|
|
2232
|
+
command: resolveCommand(scriptsOpt, commandOpt),
|
|
2233
|
+
globs,
|
|
2234
|
+
ignoreErrors,
|
|
2235
|
+
list,
|
|
2236
|
+
logger,
|
|
2237
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2238
|
+
rootPath,
|
|
2239
|
+
shell: resolveShell(scriptsOpt, commandOpt, shellOpt),
|
|
2240
|
+
});
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
// list only (explicit --list without --command)
|
|
2244
|
+
const mergedBag = ((thisCommand.parent ?? null)?.getDotenvCliOptions ?? {});
|
|
2245
|
+
const shellOnly = (opts.shell ?? cfg.shell ?? mergedBag.shell ?? false);
|
|
2246
|
+
await execShellCommandBatch({
|
|
2247
|
+
globs,
|
|
2248
|
+
ignoreErrors,
|
|
2249
|
+
list: true,
|
|
2250
|
+
logger,
|
|
2251
|
+
...(pkgCwd ? { pkgCwd } : {}),
|
|
2252
|
+
rootPath,
|
|
2253
|
+
shell: (shellOnly ?? false),
|
|
2254
|
+
});
|
|
2255
|
+
};
|
|
2256
|
+
|
|
2257
|
+
// Per-plugin config schema (optional fields; used as defaults).
|
|
2258
|
+
const ScriptSchema = z.union([
|
|
2259
|
+
z.string(),
|
|
2260
|
+
z.object({
|
|
2261
|
+
cmd: z.string(),
|
|
2262
|
+
shell: z.union([z.string(), z.boolean()]).optional(),
|
|
2263
|
+
}),
|
|
2264
|
+
]);
|
|
2265
|
+
const BatchConfigSchema = z.object({
|
|
2266
|
+
scripts: z.record(z.string(), ScriptSchema).optional(),
|
|
2267
|
+
shell: z.union([z.string(), z.boolean()]).optional(),
|
|
2268
|
+
rootPath: z.string().optional(),
|
|
2269
|
+
globs: z.string().optional(),
|
|
2270
|
+
pkgCwd: z.boolean().optional(),
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* Batch plugin for the GetDotenv CLI host.
|
|
723
2275
|
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
2276
|
+
* Mirrors the legacy batch subcommand behavior without altering the shipped CLI. * Options:
|
|
2277
|
+
* - scripts/shell: used to resolve command and shell behavior per script or global default.
|
|
2278
|
+
* - logger: defaults to console.
|
|
726
2279
|
*/
|
|
727
|
-
const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
2280
|
+
const batchPlugin = (opts = {}) => definePlugin({
|
|
2281
|
+
id: 'batch',
|
|
2282
|
+
// Host validates this when config-loader is enabled; plugins may also
|
|
2283
|
+
// re-validate at action time as a safety belt.
|
|
2284
|
+
configSchema: BatchConfigSchema,
|
|
2285
|
+
setup(cli) {
|
|
2286
|
+
const ns = cli.ns('batch');
|
|
2287
|
+
const batchCmd = ns; // capture the parent "batch" command for default-subcommand context
|
|
2288
|
+
ns.description('Batch command execution across multiple working directories.')
|
|
2289
|
+
.enablePositionalOptions()
|
|
2290
|
+
.passThroughOptions()
|
|
2291
|
+
.option('-p, --pkg-cwd', 'use nearest package directory as current working directory')
|
|
2292
|
+
.option('-r, --root-path <string>', 'path to batch root directory from current working directory', './')
|
|
2293
|
+
.option('-g, --globs <string>', 'space-delimited globs from root path', '*')
|
|
2294
|
+
.option('-c, --command <string>', 'command executed according to the base shell resolution')
|
|
2295
|
+
.option('-l, --list', 'list working directories without executing command')
|
|
2296
|
+
.option('-e, --ignore-errors', 'ignore errors and continue with next path')
|
|
2297
|
+
.argument('[command...]')
|
|
2298
|
+
.addCommand(new Command()
|
|
2299
|
+
.name('cmd')
|
|
2300
|
+
.description('execute command, conflicts with --command option (default subcommand)')
|
|
2301
|
+
.enablePositionalOptions()
|
|
2302
|
+
.passThroughOptions()
|
|
2303
|
+
.argument('[command...]')
|
|
2304
|
+
.action(buildDefaultCmdAction(cli, batchCmd, opts)), { isDefault: true })
|
|
2305
|
+
.action(buildParentAction(cli, opts));
|
|
2306
|
+
},
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
const dbg = (...args) => {
|
|
2310
|
+
if (process.env.GETDOTENV_DEBUG) {
|
|
2311
|
+
// Use stderr to avoid interfering with stdout assertions
|
|
2312
|
+
console.error('[getdotenv:alias]', ...args);
|
|
2313
|
+
}
|
|
2314
|
+
};
|
|
2315
|
+
const attachParentAlias = (cli, options, _cmd) => {
|
|
2316
|
+
const aliasSpec = typeof options.optionAlias === 'string'
|
|
2317
|
+
? { flags: options.optionAlias, description: undefined, expand: true }
|
|
2318
|
+
: options.optionAlias;
|
|
2319
|
+
if (!aliasSpec)
|
|
2320
|
+
return;
|
|
2321
|
+
const deriveKey = (flags) => {
|
|
2322
|
+
dbg('install alias option', flags);
|
|
2323
|
+
const long = flags.split(/[ ,|]+/).find((f) => f.startsWith('--')) ?? '--cmd';
|
|
2324
|
+
const name = long.replace(/^--/, '');
|
|
2325
|
+
return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
|
|
2326
|
+
};
|
|
2327
|
+
const aliasKey = deriveKey(aliasSpec.flags);
|
|
2328
|
+
// Expose the option on the parent.
|
|
2329
|
+
const desc = aliasSpec.description ??
|
|
2330
|
+
'alias of cmd subcommand; provide command tokens (variadic)';
|
|
2331
|
+
cli.option(aliasSpec.flags, desc);
|
|
2332
|
+
// Shared alias executor for either preAction or preSubcommand hooks.
|
|
2333
|
+
// Ensure we only execute once even if both hooks fire in a single parse.
|
|
2334
|
+
let aliasHandled = false;
|
|
2335
|
+
const maybeRunAlias = async (thisCommand) => {
|
|
2336
|
+
dbg('alias:maybe:start');
|
|
2337
|
+
const raw = thisCommand.rawArgs ?? [];
|
|
2338
|
+
const childNames = thisCommand.commands.flatMap((c) => [
|
|
2339
|
+
c.name(),
|
|
2340
|
+
...c.aliases(),
|
|
2341
|
+
]);
|
|
2342
|
+
const hasSub = childNames.some((n) => raw.includes(n));
|
|
2343
|
+
// Read alias value from parent opts.
|
|
2344
|
+
const o = thisCommand.opts();
|
|
2345
|
+
const val = o[aliasKey];
|
|
2346
|
+
const provided = typeof val === 'string'
|
|
2347
|
+
? val.length > 0
|
|
2348
|
+
: Array.isArray(val)
|
|
2349
|
+
? val.length > 0
|
|
2350
|
+
: false;
|
|
2351
|
+
if (!provided || hasSub) {
|
|
2352
|
+
dbg('alias:maybe:skip', { provided, hasSub });
|
|
2353
|
+
return; // not an alias-only invocation
|
|
2354
|
+
}
|
|
2355
|
+
if (aliasHandled) {
|
|
2356
|
+
dbg('alias:maybe:already-handled');
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
aliasHandled = true;
|
|
2360
|
+
dbg('alias-only invocation detected');
|
|
2361
|
+
// Merge CLI options and resolve dotenv context.
|
|
2362
|
+
const { merged } = resolveCliOptions(o, baseRootOptionDefaults, process.env.getDotenvCliOptions);
|
|
2363
|
+
const logger = merged.logger ?? console;
|
|
2364
|
+
const serviceOptions = getDotenvCliOptions2Options(merged);
|
|
2365
|
+
await cli.resolveAndLoad(serviceOptions);
|
|
2366
|
+
// Normalize alias value.
|
|
2367
|
+
const joined = typeof val === 'string'
|
|
2368
|
+
? val
|
|
2369
|
+
: Array.isArray(val)
|
|
2370
|
+
? val.map(String).join(' ')
|
|
2371
|
+
: '';
|
|
2372
|
+
const input = aliasSpec.expand === false
|
|
2373
|
+
? joined
|
|
2374
|
+
: (dotenvExpandFromProcessEnv(joined) ?? joined);
|
|
2375
|
+
dbg('resolved input', { input });
|
|
2376
|
+
const resolved = resolveCommand(merged.scripts, input);
|
|
2377
|
+
const lg = logger;
|
|
2378
|
+
if (merged.debug) {
|
|
2379
|
+
(lg.debug ?? lg.log)('\n*** command ***\n', `'${resolved}'`);
|
|
2380
|
+
}
|
|
2381
|
+
const { logger: _omit, ...envBag } = merged;
|
|
2382
|
+
// Test guard: when running under tests, prefer stdio: 'inherit' to avoid
|
|
2383
|
+
// assertions depending on captured stdio; ignore GETDOTENV_STDIO/capture.
|
|
2384
|
+
const underTests = process.env.GETDOTENV_TEST === '1' ||
|
|
2385
|
+
typeof process.env.VITEST_WORKER_ID === 'string';
|
|
2386
|
+
const forceExit = process.env.GETDOTENV_FORCE_EXIT === '1';
|
|
2387
|
+
const capture = !underTests &&
|
|
2388
|
+
(process.env.GETDOTENV_STDIO === 'pipe' ||
|
|
2389
|
+
Boolean(merged.capture));
|
|
2390
|
+
dbg('run:start', { capture, shell: merged.shell });
|
|
2391
|
+
// Prefer explicit env injection: include resolved dotenv map to avoid leaking
|
|
2392
|
+
// parent process.env secrets when exclusions are set.
|
|
2393
|
+
const ctx = cli.getCtx();
|
|
2394
|
+
const dotenv = (ctx?.dotenv ?? {});
|
|
2395
|
+
// Diagnostics: --trace [keys...]
|
|
2396
|
+
const traceOpt = merged.trace;
|
|
2397
|
+
if (traceOpt) {
|
|
2398
|
+
const parentKeys = Object.keys(process.env);
|
|
2399
|
+
const dotenvKeys = Object.keys(dotenv);
|
|
2400
|
+
const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
|
|
2401
|
+
const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
|
|
2402
|
+
const childEnvPreview = {
|
|
2403
|
+
...process.env,
|
|
2404
|
+
...dotenv,
|
|
758
2405
|
};
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (options.dynamic && Object.keys(options.dynamic).length > 0) {
|
|
771
|
-
dynamic = options.dynamic;
|
|
2406
|
+
for (const k of keys) {
|
|
2407
|
+
const parent = process.env[k];
|
|
2408
|
+
const dot = dotenv[k];
|
|
2409
|
+
const final = childEnvPreview[k];
|
|
2410
|
+
const origin = dot !== undefined
|
|
2411
|
+
? 'dotenv'
|
|
2412
|
+
: parent !== undefined
|
|
2413
|
+
? 'parent'
|
|
2414
|
+
: 'unset';
|
|
2415
|
+
process.stderr.write(`[trace] key=${k} origin=${origin} parent=${parent ?? ''} dotenv=${dot ?? ''} final=${final ?? ''}\n`);
|
|
2416
|
+
}
|
|
772
2417
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
2418
|
+
let exitCode = Number.NaN;
|
|
2419
|
+
try {
|
|
2420
|
+
// Resolve shell and preserve argv for Node -e snippets under shell-off.
|
|
2421
|
+
const shellSetting = resolveShell(merged.scripts, input, merged.shell);
|
|
2422
|
+
let commandArg = resolved;
|
|
2423
|
+
/** * Special-case: when shell is OFF and no script alias remap occurred
|
|
2424
|
+
* (resolved === input), treat a Node eval payload as an argv array to
|
|
2425
|
+
* avoid lossy re-tokenization of the code string.
|
|
2426
|
+
*
|
|
2427
|
+
* Examples handled:
|
|
2428
|
+
* "node -e \"console.log(JSON.stringify(...))\""
|
|
2429
|
+
* "node --eval 'console.log(...)'"
|
|
2430
|
+
*
|
|
2431
|
+
* We peel exactly one pair of symmetric outer quotes from the code
|
|
2432
|
+
* argument when present; inner quotes remain untouched.
|
|
2433
|
+
*/
|
|
2434
|
+
if (shellSetting === false && resolved === input) {
|
|
2435
|
+
// Helper: strip one symmetric outer quote layer
|
|
2436
|
+
const stripOne = (s) => {
|
|
2437
|
+
if (s.length < 2)
|
|
2438
|
+
return s;
|
|
2439
|
+
const a = s.charAt(0);
|
|
2440
|
+
const b = s.charAt(s.length - 1);
|
|
2441
|
+
const symmetric = (a === '"' && b === '"') || (a === "'" && b === "'");
|
|
2442
|
+
return symmetric ? s.slice(1, -1) : s;
|
|
2443
|
+
};
|
|
2444
|
+
// Normalize whole input once for robust matching
|
|
2445
|
+
const normalized = stripOne(input.trim());
|
|
2446
|
+
// First try a lightweight regex on the normalized string
|
|
2447
|
+
const m = /^\s*node\s+(--eval|-e)\s+([\s\S]+)$/i.exec(normalized);
|
|
2448
|
+
if (m && typeof m[1] === 'string' && typeof m[2] === 'string') {
|
|
2449
|
+
const evalFlag = m[1];
|
|
2450
|
+
let codeArg = m[2].trim();
|
|
2451
|
+
codeArg = stripOne(codeArg);
|
|
2452
|
+
const flag = evalFlag.startsWith('--') ? '--eval' : '-e';
|
|
2453
|
+
commandArg = ['node', flag, codeArg];
|
|
2454
|
+
}
|
|
2455
|
+
else {
|
|
2456
|
+
// Fallback: tokenize and detect node -e/--eval form
|
|
2457
|
+
const parts = tokenize(input);
|
|
2458
|
+
if (parts.length >= 3) {
|
|
2459
|
+
// Narrow under noUncheckedIndexedAccess
|
|
2460
|
+
const p0 = parts[0];
|
|
2461
|
+
const p1 = parts[1];
|
|
2462
|
+
if (p0?.toLowerCase() === 'node' &&
|
|
2463
|
+
(p1 === '-e' || p1 === '--eval')) {
|
|
2464
|
+
commandArg = parts;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
exitCode = await runCommand(commandArg, shellSetting, {
|
|
2470
|
+
env: {
|
|
2471
|
+
...process.env,
|
|
2472
|
+
...dotenv,
|
|
2473
|
+
getDotenvCliOptions: JSON.stringify(envBag),
|
|
2474
|
+
},
|
|
2475
|
+
stdio: capture ? 'pipe' : 'inherit',
|
|
2476
|
+
});
|
|
2477
|
+
dbg('run:done', { exitCode });
|
|
776
2478
|
}
|
|
777
|
-
|
|
2479
|
+
catch (err) {
|
|
2480
|
+
const code = typeof err.exitCode === 'number'
|
|
2481
|
+
? err.exitCode
|
|
2482
|
+
: 1;
|
|
2483
|
+
dbg('run:error', { exitCode: code, error: String(err) });
|
|
2484
|
+
if (!underTests) {
|
|
2485
|
+
dbg('process.exit (error path)', { exitCode: code });
|
|
2486
|
+
process.exit(code);
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
dbg('process.exit suppressed for tests (error path)', {
|
|
2490
|
+
exitCode: code,
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
if (!Number.isNaN(exitCode)) {
|
|
2496
|
+
dbg('process.exit', { exitCode });
|
|
2497
|
+
process.exit(exitCode);
|
|
2498
|
+
}
|
|
2499
|
+
// Fallback: Some environments may not surface a numeric exitCode even on success.
|
|
2500
|
+
// Always terminate alias-only invocations outside tests to avoid hanging the process,
|
|
2501
|
+
// regardless of capture/GETDOTENV_STDIO. Under tests, suppress to keep the runner alive.
|
|
2502
|
+
if (!underTests) {
|
|
2503
|
+
dbg('process.exit (fallback: non-numeric exitCode)', { exitCode: 0 });
|
|
2504
|
+
process.exit(0);
|
|
2505
|
+
}
|
|
2506
|
+
else {
|
|
2507
|
+
dbg('process.exit (fallback suppressed for tests: non-numeric exitCode)', { exitCode: 0 });
|
|
2508
|
+
}
|
|
2509
|
+
// Optional last-resort guard: force an exit on the next tick when enabled.
|
|
2510
|
+
// Intended for diagnosing environments where the process appears to linger
|
|
2511
|
+
// despite reaching the success/error handlers above. Disabled under tests.
|
|
2512
|
+
if (forceExit) {
|
|
778
2513
|
try {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
2514
|
+
if (process.env.GETDOTENV_DEBUG_VERBOSE) {
|
|
2515
|
+
const getHandles = process._getActiveHandles;
|
|
2516
|
+
const handles = typeof getHandles === 'function' ? getHandles() : [];
|
|
2517
|
+
dbg('active handles before forced exit', {
|
|
2518
|
+
count: Array.isArray(handles) ? handles.length : undefined,
|
|
784
2519
|
});
|
|
2520
|
+
}
|
|
785
2521
|
}
|
|
786
2522
|
catch {
|
|
787
|
-
|
|
2523
|
+
// best-effort only
|
|
788
2524
|
}
|
|
2525
|
+
const code = Number.isNaN(exitCode) ? 0 : exitCode;
|
|
2526
|
+
dbg('process.exit (forced)', { exitCode: code });
|
|
2527
|
+
setImmediate(() => process.exit(code));
|
|
789
2528
|
}
|
|
790
|
-
}
|
|
791
|
-
//
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
await fs.writeFile(outputPathResolved, Object.keys(dotenvForOutput).reduce((contents, key) => {
|
|
799
|
-
const value = dotenvForOutput[key] ?? '';
|
|
800
|
-
return `${contents}${key}=${value.includes('\n') ? `"${value}"` : value}\n`;
|
|
801
|
-
}, ''), { encoding: 'utf-8' });
|
|
802
|
-
resultDotenv = dotenvForOutput;
|
|
803
|
-
}
|
|
804
|
-
// Log result.
|
|
805
|
-
if (log)
|
|
806
|
-
logger.log(resultDotenv);
|
|
807
|
-
// Load process.env.
|
|
808
|
-
if (loadProcess)
|
|
809
|
-
Object.assign(process.env, resultDotenv);
|
|
810
|
-
return resultDotenv;
|
|
2529
|
+
};
|
|
2530
|
+
// Execute alias-only invocations whether the root handles the action // itself (preAction) or Commander routes to a default subcommand (preSubcommand).
|
|
2531
|
+
cli.hook('preAction', async (thisCommand, _actionCommand) => {
|
|
2532
|
+
await maybeRunAlias(thisCommand);
|
|
2533
|
+
});
|
|
2534
|
+
cli.hook('preSubcommand', async (thisCommand) => {
|
|
2535
|
+
await maybeRunAlias(thisCommand);
|
|
2536
|
+
});
|
|
811
2537
|
};
|
|
812
2538
|
|
|
813
|
-
|
|
814
|
-
* Resolve a tri-state optional boolean flag under exactOptionalPropertyTypes.
|
|
815
|
-
* - If the user explicitly enabled the flag, return true.
|
|
816
|
-
* - If the user explicitly disabled (the "...-off" variant), return undefined (unset).
|
|
817
|
-
* - Otherwise, adopt the default (true → set; false/undefined → unset).
|
|
818
|
-
*
|
|
819
|
-
* @param exclude - The "on" flag value as parsed by Commander.
|
|
820
|
-
* @param excludeOff - The "off" toggle (present when specified) as parsed by Commander.
|
|
821
|
-
* @param defaultValue - The generator default to adopt when no explicit toggle is present.
|
|
822
|
-
* @returns boolean | undefined — use `undefined` to indicate "unset" (do not emit).
|
|
2539
|
+
/**+ Cmd plugin: executes a command using the current getdotenv CLI context.
|
|
823
2540
|
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
|
|
829
|
-
|
|
2541
|
+
* - Joins positional args into a single command string.
|
|
2542
|
+
* - Resolves scripts and shell settings using shared helpers.
|
|
2543
|
+
* - Forwards merged CLI options to subprocesses via
|
|
2544
|
+
* process.env.getDotenvCliOptions for nested CLI behavior. */
|
|
2545
|
+
const cmdPlugin = (options = {}) => definePlugin({
|
|
2546
|
+
id: 'cmd',
|
|
2547
|
+
setup(cli) {
|
|
2548
|
+
const aliasSpec = typeof options.optionAlias === 'string'
|
|
2549
|
+
? { flags: options.optionAlias}
|
|
2550
|
+
: options.optionAlias;
|
|
2551
|
+
const deriveKey = (flags) => {
|
|
2552
|
+
const long = flags.split(/[ ,|]+/).find((f) => f.startsWith('--')) ?? '--cmd';
|
|
2553
|
+
const name = long.replace(/^--/, '');
|
|
2554
|
+
return name.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
|
|
2555
|
+
};
|
|
2556
|
+
const aliasKey = aliasSpec ? deriveKey(aliasSpec.flags) : undefined;
|
|
2557
|
+
const cmd = new Command()
|
|
2558
|
+
.name('cmd')
|
|
2559
|
+
.description('Batch execute command according to the --shell option, conflicts with --command option (default subcommand)')
|
|
2560
|
+
.configureHelp({ showGlobalOptions: true })
|
|
2561
|
+
.enablePositionalOptions()
|
|
2562
|
+
.passThroughOptions()
|
|
2563
|
+
.argument('[command...]')
|
|
2564
|
+
.action(async (commandParts, _opts, thisCommand) => {
|
|
2565
|
+
// Commander passes positional tokens as the first action argument
|
|
2566
|
+
const args = Array.isArray(commandParts) ? commandParts : [];
|
|
2567
|
+
// No-op when invoked as the default command with no args.
|
|
2568
|
+
if (args.length === 0)
|
|
2569
|
+
return;
|
|
2570
|
+
const parent = thisCommand.parent;
|
|
2571
|
+
if (!parent)
|
|
2572
|
+
throw new Error('parent command not found'); // Conflict detection: if an alias option is present on parent, do not
|
|
2573
|
+
// also accept positional cmd args.
|
|
2574
|
+
if (aliasKey) {
|
|
2575
|
+
const pv = parent.opts();
|
|
2576
|
+
const ov = pv[aliasKey];
|
|
2577
|
+
if (ov !== undefined) {
|
|
2578
|
+
const merged = parent.getDotenvCliOptions ?? {};
|
|
2579
|
+
const logger = merged.logger ?? console;
|
|
2580
|
+
const lr = logger;
|
|
2581
|
+
const emit = lr.error ?? lr.log;
|
|
2582
|
+
emit(`--${aliasKey} option conflicts with cmd subcommand.`);
|
|
2583
|
+
process.exit(0);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
// Merged CLI options are persisted by the shipped CLI preSubcommand hook.
|
|
2587
|
+
const merged = parent.getDotenvCliOptions ?? {};
|
|
2588
|
+
const logger = merged.logger ?? console;
|
|
2589
|
+
// Join positional args into the command string.
|
|
2590
|
+
const input = args.map(String).join(' ');
|
|
2591
|
+
// Resolve command and shell using shared helpers.
|
|
2592
|
+
const scripts = merged.scripts;
|
|
2593
|
+
const shell = merged.shell;
|
|
2594
|
+
const resolved = resolveCommand(scripts, input);
|
|
2595
|
+
if (merged.debug) {
|
|
2596
|
+
const lg = logger;
|
|
2597
|
+
(lg.debug ?? lg.log)('\n*** command ***\n', `'${resolved}'`);
|
|
2598
|
+
}
|
|
2599
|
+
// Round-trip CLI options for nested getdotenv invocations.
|
|
2600
|
+
// Omit logger (functions are not serializable).
|
|
2601
|
+
const { logger: _omit, ...envBag } = merged;
|
|
2602
|
+
const capture = process.env.GETDOTENV_STDIO === 'pipe' ||
|
|
2603
|
+
Boolean(merged.capture);
|
|
2604
|
+
// Prefer explicit env injection: pass the resolved dotenv map to the child.
|
|
2605
|
+
// This avoids leaking prior secrets from the parent process.env when
|
|
2606
|
+
// exclusions (e.g., --exclude-private) are in effect.
|
|
2607
|
+
const host = cli;
|
|
2608
|
+
const ctx = host.getCtx();
|
|
2609
|
+
const dotenv = (ctx?.dotenv ?? {});
|
|
2610
|
+
// Diagnostics: --trace [keys...] (space-delimited keys if provided; all keys when true)
|
|
2611
|
+
const traceOpt = merged.trace;
|
|
2612
|
+
if (traceOpt) {
|
|
2613
|
+
// Determine keys to trace: all keys (parent ∪ dotenv) or selected.
|
|
2614
|
+
const parentKeys = Object.keys(process.env);
|
|
2615
|
+
const dotenvKeys = Object.keys(dotenv);
|
|
2616
|
+
const allKeys = Array.from(new Set([...parentKeys, ...dotenvKeys])).sort();
|
|
2617
|
+
const keys = Array.isArray(traceOpt) ? traceOpt : allKeys;
|
|
2618
|
+
// Child env preview (as composed below; excluding getDotenvCliOptions)
|
|
2619
|
+
const childEnvPreview = {
|
|
2620
|
+
...process.env,
|
|
2621
|
+
...dotenv,
|
|
2622
|
+
};
|
|
2623
|
+
for (const k of keys) {
|
|
2624
|
+
const parent = process.env[k];
|
|
2625
|
+
const dot = dotenv[k];
|
|
2626
|
+
const final = childEnvPreview[k];
|
|
2627
|
+
const origin = dot !== undefined
|
|
2628
|
+
? 'dotenv'
|
|
2629
|
+
: parent !== undefined
|
|
2630
|
+
? 'parent'
|
|
2631
|
+
: 'unset';
|
|
2632
|
+
// Emit concise diagnostic line to stderr.
|
|
2633
|
+
process.stderr.write(`[trace] key=${k} origin=${origin} parent=${parent ?? ''} dotenv=${dot ?? ''} final=${final ?? ''}\n`);
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
const shellSetting = resolveShell(scripts, input, shell);
|
|
2637
|
+
/**
|
|
2638
|
+
* Preserve original argv array when:
|
|
2639
|
+
* - shell is OFF (plain execa), and
|
|
2640
|
+
* - no script alias remap occurred (resolved === input).
|
|
2641
|
+
*
|
|
2642
|
+
* This avoids lossy re-tokenization of code snippets such as:
|
|
2643
|
+
* node -e "console.log(process.env.APP_SECRET ?? '')"
|
|
2644
|
+
* where quotes may have been stripped by the parent shell and
|
|
2645
|
+
* spaces inside the code must remain a single argument.
|
|
2646
|
+
*/
|
|
2647
|
+
const commandArg = shellSetting === false && resolved === input
|
|
2648
|
+
? args.map(String)
|
|
2649
|
+
: resolved;
|
|
2650
|
+
await runCommand(commandArg, shellSetting, {
|
|
2651
|
+
env: {
|
|
2652
|
+
...process.env,
|
|
2653
|
+
...dotenv,
|
|
2654
|
+
getDotenvCliOptions: JSON.stringify(envBag),
|
|
2655
|
+
},
|
|
2656
|
+
stdio: capture ? 'pipe' : 'inherit',
|
|
2657
|
+
});
|
|
2658
|
+
});
|
|
2659
|
+
if (options.asDefault)
|
|
2660
|
+
cli.addCommand(cmd, { isDefault: true });
|
|
2661
|
+
else
|
|
2662
|
+
cli.addCommand(cmd);
|
|
2663
|
+
// Parent-attached option alias (optional).
|
|
2664
|
+
if (aliasSpec)
|
|
2665
|
+
attachParentAlias(cli, options);
|
|
2666
|
+
},
|
|
2667
|
+
});
|
|
2668
|
+
|
|
830
2669
|
/**
|
|
831
|
-
*
|
|
832
|
-
* If excludeAll is set and the individual "...-off" is not, force true.
|
|
833
|
-
* If excludeAllOff is set and the individual flag is not explicitly set, unset.
|
|
834
|
-
* Otherwise, adopt the default (true → set; false/undefined → unset).
|
|
2670
|
+
* Demo plugin (educational).
|
|
835
2671
|
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
2672
|
+
* Purpose
|
|
2673
|
+
* - Showcase how to build a plugin for the GetDotenv CLI host.
|
|
2674
|
+
* - Demonstrate:
|
|
2675
|
+
* - Accessing the resolved dotenv context (ctx).
|
|
2676
|
+
* - Executing child processes with explicit env injection.
|
|
2677
|
+
* - Resolving commands via scripts and honoring per-script shell overrides.
|
|
2678
|
+
* - Thin adapters: business logic stays minimal; use shared helpers.
|
|
841
2679
|
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
? true
|
|
847
|
-
: excludeAllOff && !exclude
|
|
848
|
-
? undefined
|
|
849
|
-
: defaultValue
|
|
850
|
-
? true
|
|
851
|
-
: undefined;
|
|
852
|
-
/**
|
|
853
|
-
* exactOptionalPropertyTypes-safe setter for optional boolean flags:
|
|
854
|
-
* delete when undefined; assign when defined — without requiring an index signature on T.
|
|
2680
|
+
* Key host APIs used:
|
|
2681
|
+
* - definePlugin: declare a plugin with setup and optional afterResolve.
|
|
2682
|
+
* - cli.ns(name): create a namespaced subcommand under the root CLI.
|
|
2683
|
+
* - cli.getCtx(): access \{ optionsResolved, dotenv, plugins?, pluginConfigs? \}.
|
|
855
2684
|
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
2685
|
+
* Design notes
|
|
2686
|
+
* - We use the shared runCommand() helper so behavior matches the built-in
|
|
2687
|
+
* cmd/batch plugins (env sanitization, plain vs shell execution, stdio).
|
|
2688
|
+
* - We inject ctx.dotenv into child env explicitly to avoid bleeding prior
|
|
2689
|
+
* secrets from process.env when exclusions are set (e.g., --exclude-private).
|
|
2690
|
+
* - We resolve scripts and shell using shared helpers to honor overrides:
|
|
2691
|
+
* resolveCommand(scripts, input) and resolveShell(scripts, input, shell).
|
|
860
2692
|
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
2693
|
+
* Usage (examples)
|
|
2694
|
+
* getdotenv demo ctx
|
|
2695
|
+
* getdotenv demo run --print APP_SETTING
|
|
2696
|
+
* getdotenv demo script echo OK
|
|
2697
|
+
* getdotenv --trace demo run --print ENV_SETTING
|
|
863
2698
|
*/
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
2699
|
+
const demoPlugin = () => definePlugin({
|
|
2700
|
+
id: 'demo',
|
|
2701
|
+
setup(cli) {
|
|
2702
|
+
const logger = console;
|
|
2703
|
+
const ns = cli
|
|
2704
|
+
.ns('demo')
|
|
2705
|
+
.description('Educational demo of host/plugin features (context, child exec, scripts/shell)');
|
|
2706
|
+
/**
|
|
2707
|
+
* demo ctx
|
|
2708
|
+
* Print a summary of the current dotenv context.
|
|
2709
|
+
*
|
|
2710
|
+
* Notes:
|
|
2711
|
+
* - The host resolves context once per invocation in a preSubcommand hook
|
|
2712
|
+
* (added by enhanceGetDotenvCli.passOptions() in the shipped CLI).
|
|
2713
|
+
* - ctx.dotenv contains the final merged values after overlays/dynamics.
|
|
2714
|
+
*/
|
|
2715
|
+
ns.command('ctx')
|
|
2716
|
+
.description('Print a summary of the current dotenv context')
|
|
2717
|
+
.action(() => {
|
|
2718
|
+
const ctx = cli.getCtx();
|
|
2719
|
+
const dotenv = ctx?.dotenv ?? {};
|
|
2720
|
+
const keys = Object.keys(dotenv).sort();
|
|
2721
|
+
const sample = keys.slice(0, 5);
|
|
2722
|
+
logger.log('[demo] Context summary:');
|
|
2723
|
+
logger.log(`- keys: ${keys.length.toString()}`);
|
|
2724
|
+
logger.log(`- sample keys: ${sample.join(', ') || '(none)'}`);
|
|
2725
|
+
logger.log('- tip: use "--trace [keys...]" for per-key diagnostics');
|
|
2726
|
+
});
|
|
2727
|
+
/**
|
|
2728
|
+
* demo run [--print KEY]
|
|
2729
|
+
* Execute a small child process that prints a dotenv value.
|
|
2730
|
+
*
|
|
2731
|
+
* Design:
|
|
2732
|
+
* - Use shell-off + argv array to avoid cross-platform quoting pitfalls.
|
|
2733
|
+
* - Inject ctx.dotenv explicitly into the child env.
|
|
2734
|
+
* - Inherit stdio so output streams live (works well outside CI).
|
|
2735
|
+
*
|
|
2736
|
+
* Tip:
|
|
2737
|
+
* - For deterministic capture in CI, run with "--capture" (or set
|
|
2738
|
+
* GETDOTENV_STDIO=pipe). The shipped CLI honors both.
|
|
2739
|
+
*/
|
|
2740
|
+
ns.command('run')
|
|
2741
|
+
.description('Run a small child process under the current dotenv (shell-off)')
|
|
2742
|
+
.option('--print <key>', 'dotenv key to print', 'APP_SETTING')
|
|
2743
|
+
.action(async (opts) => {
|
|
2744
|
+
const key = typeof opts.print === 'string' && opts.print.length > 0
|
|
2745
|
+
? opts.print
|
|
2746
|
+
: 'APP_SETTING';
|
|
2747
|
+
// Build a minimal node -e payload via argv array (avoid quoting issues).
|
|
2748
|
+
const code = `console.log(process.env.${key} ?? "")`;
|
|
2749
|
+
const ctx = cli.getCtx();
|
|
2750
|
+
const dotenv = (ctx?.dotenv ?? {});
|
|
2751
|
+
// Inherit stdio for an interactive demo. Use --capture for CI.
|
|
2752
|
+
await runCommand(['node', '-e', code], false, {
|
|
2753
|
+
env: { ...process.env, ...dotenv },
|
|
2754
|
+
stdio: 'inherit',
|
|
2755
|
+
});
|
|
2756
|
+
});
|
|
2757
|
+
/**
|
|
2758
|
+
* demo script [command...]
|
|
2759
|
+
* Resolve and execute a command using the current scripts table and
|
|
2760
|
+
* shell preference (with per-script overrides).
|
|
2761
|
+
*
|
|
2762
|
+
* How it works:
|
|
2763
|
+
* - We read the merged CLI options persisted by the shipped CLI’s
|
|
2764
|
+
* passOptions() hook on the current command instance’s parent.
|
|
2765
|
+
* - resolveCommand resolves a script name → cmd or passes through a raw
|
|
2766
|
+
* command string.
|
|
2767
|
+
* - resolveShell chooses the appropriate shell:
|
|
2768
|
+
* scripts[name].shell ?? global shell (string|boolean).
|
|
2769
|
+
*/
|
|
2770
|
+
ns.command('script')
|
|
2771
|
+
.description('Resolve a command via scripts and execute it with the proper shell')
|
|
2772
|
+
.argument('[command...]')
|
|
2773
|
+
.action(async (commandParts, _opts, thisCommand) => {
|
|
2774
|
+
// Safely access the parent’s merged options (installed by passOptions()).
|
|
2775
|
+
const parent = thisCommand.parent;
|
|
2776
|
+
const bag = (parent?.getDotenvCliOptions ?? {});
|
|
2777
|
+
const input = Array.isArray(commandParts)
|
|
2778
|
+
? commandParts.map(String).join(' ')
|
|
2779
|
+
: '';
|
|
2780
|
+
if (!input) {
|
|
2781
|
+
logger.log('[demo] Please provide a command or script name, e.g. "echo OK" or "git-status".');
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
const resolved = resolveCommand(bag?.scripts, input);
|
|
2785
|
+
const shell = resolveShell(bag?.scripts, input, bag?.shell);
|
|
2786
|
+
// Compose child env (parent + ctx.dotenv). This mirrors cmd/batch behavior.
|
|
2787
|
+
const ctx = cli.getCtx();
|
|
2788
|
+
const dotenv = (ctx?.dotenv ?? {});
|
|
2789
|
+
await runCommand(resolved, shell, {
|
|
2790
|
+
env: { ...process.env, ...dotenv },
|
|
2791
|
+
stdio: 'inherit',
|
|
2792
|
+
});
|
|
2793
|
+
});
|
|
2794
|
+
},
|
|
2795
|
+
/**
|
|
2796
|
+
* Optional: afterResolve can initialize per-plugin state using ctx.dotenv.
|
|
2797
|
+
* For the demo we just log once to hint where such logic would live.
|
|
2798
|
+
*/
|
|
2799
|
+
afterResolve(_cli, ctx) {
|
|
2800
|
+
const keys = Object.keys(ctx.dotenv);
|
|
2801
|
+
if (keys.length > 0) {
|
|
2802
|
+
// Keep noise low; a single-line breadcrumb is sufficient for the demo.
|
|
2803
|
+
console.error('[demo] afterResolve: dotenv keys loaded:', keys.length);
|
|
2804
|
+
}
|
|
2805
|
+
},
|
|
2806
|
+
});
|
|
873
2807
|
|
|
2808
|
+
const ensureDir = async (p) => {
|
|
2809
|
+
await fs.ensureDir(p);
|
|
2810
|
+
return p;
|
|
2811
|
+
};
|
|
2812
|
+
const writeFile = async (dest, data) => {
|
|
2813
|
+
await ensureDir(path.dirname(dest));
|
|
2814
|
+
await fs.writeFile(dest, data, 'utf-8');
|
|
2815
|
+
};
|
|
2816
|
+
const copyTextFile = async (src, dest, substitutions) => {
|
|
2817
|
+
const contents = await fs.readFile(src, 'utf-8');
|
|
2818
|
+
const out = substitutions && Object.keys(substitutions).length > 0
|
|
2819
|
+
? Object.entries(substitutions).reduce((acc, [k, v]) => acc.split(k).join(v), contents)
|
|
2820
|
+
: contents;
|
|
2821
|
+
await writeFile(dest, out);
|
|
2822
|
+
};
|
|
874
2823
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
// Get parent command GetDotenvCliOptions.
|
|
891
|
-
const parentGetDotenvCliOptions = process.env.getDotenvCliOptions
|
|
892
|
-
? JSON.parse(process.env.getDotenvCliOptions)
|
|
893
|
-
: undefined;
|
|
894
|
-
// Get raw CLI options from commander.
|
|
895
|
-
const rawCliOptions = thisCommand.opts();
|
|
896
|
-
// Extract current GetDotenvCliOptions from raw CLI options.
|
|
897
|
-
const { command, debugOff, excludeAll, excludeAllOff, excludeDynamicOff, excludeEnvOff, excludeGlobalOff, excludePrivateOff, excludePublicOff, loadProcessOff, logOff, scripts, shellOff, ...rawCliOptionsRest } = rawCliOptions;
|
|
898
|
-
const currentGetDotenvCliOptions = rawCliOptionsRest;
|
|
899
|
-
if (scripts)
|
|
900
|
-
currentGetDotenvCliOptions.scripts = JSON.parse(scripts);
|
|
901
|
-
// Merge current & parent GetDotenvCliOptions (parent < current).
|
|
902
|
-
const mergedGetDotenvCliOptions = defaultsDeep((parentGetDotenvCliOptions ?? {}), currentGetDotenvCliOptions);
|
|
903
|
-
// Resolve flags using defaults + current + exclude-all toggles.
|
|
904
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'debug', resolveExclusion(mergedGetDotenvCliOptions.debug, debugOff, defaults.debug));
|
|
905
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'excludeDynamic', resolveExclusionAll(mergedGetDotenvCliOptions.excludeDynamic, excludeDynamicOff, defaults.excludeDynamic, excludeAll, excludeAllOff));
|
|
906
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'excludeEnv', resolveExclusionAll(mergedGetDotenvCliOptions.excludeEnv, excludeEnvOff, defaults.excludeEnv, excludeAll, excludeAllOff));
|
|
907
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'excludeGlobal', resolveExclusionAll(mergedGetDotenvCliOptions.excludeGlobal, excludeGlobalOff, defaults.excludeGlobal, excludeAll, excludeAllOff));
|
|
908
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'excludePrivate', resolveExclusionAll(mergedGetDotenvCliOptions.excludePrivate, excludePrivateOff, defaults.excludePrivate, excludeAll, excludeAllOff));
|
|
909
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'excludePublic', resolveExclusionAll(mergedGetDotenvCliOptions.excludePublic, excludePublicOff, defaults.excludePublic, excludeAll, excludeAllOff));
|
|
910
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'log', resolveExclusion(mergedGetDotenvCliOptions.log, logOff, defaults.log));
|
|
911
|
-
setOptionalFlag(mergedGetDotenvCliOptions, 'loadProcess', resolveExclusion(mergedGetDotenvCliOptions.loadProcess, loadProcessOff, defaults.loadProcess));
|
|
912
|
-
// Normalize shell for predictability: explicit default shell per OS.
|
|
913
|
-
const defaultShell = process.platform === 'win32' ? 'powershell.exe' : '/bin/bash';
|
|
914
|
-
let resolvedShell = mergedGetDotenvCliOptions.shell;
|
|
915
|
-
if (shellOff)
|
|
916
|
-
resolvedShell = false;
|
|
917
|
-
else if (resolvedShell === true || resolvedShell === undefined) {
|
|
918
|
-
resolvedShell = defaultShell;
|
|
2824
|
+
* Ensure a set of lines exist (exact match) in a file. Creates the file
|
|
2825
|
+
* when missing. Returns whether it was created or changed.
|
|
2826
|
+
*/
|
|
2827
|
+
const ensureLines = async (filePath, lines) => {
|
|
2828
|
+
const exists = await fs.pathExists(filePath);
|
|
2829
|
+
const current = exists ? await fs.readFile(filePath, 'utf-8') : '';
|
|
2830
|
+
const curLines = current.split(/\r?\n/);
|
|
2831
|
+
const have = new Set(curLines.filter((l) => l.length > 0));
|
|
2832
|
+
let mutated = false;
|
|
2833
|
+
for (const l of lines) {
|
|
2834
|
+
if (!have.has(l)) {
|
|
2835
|
+
curLines.push(l);
|
|
2836
|
+
have.add(l);
|
|
2837
|
+
mutated = true;
|
|
2838
|
+
}
|
|
919
2839
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
2840
|
+
// Normalize to LF and ensure trailing newline
|
|
2841
|
+
const next = curLines.filter((l) => l.length > 0).join('\n') + '\n';
|
|
2842
|
+
if (!exists) {
|
|
2843
|
+
await writeFile(filePath, next);
|
|
2844
|
+
return { created: true, changed: true };
|
|
923
2845
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
2846
|
+
if (mutated) {
|
|
2847
|
+
await fs.writeFile(filePath, next, 'utf-8');
|
|
2848
|
+
return { created: false, changed: true };
|
|
927
2849
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
2850
|
+
return { created: false, changed: false };
|
|
2851
|
+
};
|
|
2852
|
+
|
|
2853
|
+
// Templates root used by the scaffolder
|
|
2854
|
+
const TEMPLATES_ROOT = path.resolve('templates');
|
|
2855
|
+
|
|
2856
|
+
const planConfigCopies = ({ format, withLocal, destRoot, }) => {
|
|
2857
|
+
const copies = [];
|
|
2858
|
+
if (format === 'json') {
|
|
2859
|
+
copies.push({
|
|
2860
|
+
src: path.join(TEMPLATES_ROOT, 'config', 'json', 'public', 'getdotenv.config.json'),
|
|
2861
|
+
dest: path.join(destRoot, 'getdotenv.config.json'),
|
|
933
2862
|
});
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
// Persist GetDotenvCliOptions in command for subcommand access.
|
|
941
|
-
thisCommand.getDotenvCliOptions = mergedGetDotenvCliOptions;
|
|
942
|
-
// Execute getdotenv.
|
|
943
|
-
const dotenv = await getDotenv(getDotenvCliOptions2Options(mergedGetDotenvCliOptions));
|
|
944
|
-
if (mergedGetDotenvCliOptions.debug)
|
|
945
|
-
logger.debug('\n*** getDotenv output ***\n', dotenv);
|
|
946
|
-
// Execute post-hook.
|
|
947
|
-
if (postHook)
|
|
948
|
-
await postHook(dotenv);
|
|
949
|
-
// Execute command.
|
|
950
|
-
const args = thisCommand.args ?? [];
|
|
951
|
-
const isCommand = typeof command === 'string' && command.length > 0;
|
|
952
|
-
if (isCommand && args.length > 0) {
|
|
953
|
-
const lr = logger;
|
|
954
|
-
(lr.error ?? lr.log)(`--command option conflicts with cmd subcommand.`);
|
|
955
|
-
process.exit(0);
|
|
2863
|
+
if (withLocal) {
|
|
2864
|
+
copies.push({
|
|
2865
|
+
src: path.join(TEMPLATES_ROOT, 'config', 'json', 'local', 'getdotenv.config.local.json'),
|
|
2866
|
+
dest: path.join(destRoot, 'getdotenv.config.local.json'),
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
956
2869
|
}
|
|
957
|
-
if (
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
2870
|
+
else if (format === 'yaml') {
|
|
2871
|
+
copies.push({
|
|
2872
|
+
src: path.join(TEMPLATES_ROOT, 'config', 'yaml', 'public', 'getdotenv.config.yaml'),
|
|
2873
|
+
dest: path.join(destRoot, 'getdotenv.config.yaml'),
|
|
2874
|
+
});
|
|
2875
|
+
if (withLocal) {
|
|
2876
|
+
copies.push({
|
|
2877
|
+
src: path.join(TEMPLATES_ROOT, 'config', 'yaml', 'local', 'getdotenv.config.local.yaml'),
|
|
2878
|
+
dest: path.join(destRoot, 'getdotenv.config.local.yaml'),
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
else if (format === 'js') {
|
|
2883
|
+
copies.push({
|
|
2884
|
+
src: path.join(TEMPLATES_ROOT, 'config', 'js', 'getdotenv.config.js'),
|
|
2885
|
+
dest: path.join(destRoot, 'getdotenv.config.js'),
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
else {
|
|
2889
|
+
copies.push({
|
|
2890
|
+
src: path.join(TEMPLATES_ROOT, 'config', 'ts', 'getdotenv.config.ts'),
|
|
2891
|
+
dest: path.join(destRoot, 'getdotenv.config.ts'),
|
|
972
2892
|
});
|
|
973
2893
|
}
|
|
2894
|
+
return copies;
|
|
2895
|
+
};
|
|
2896
|
+
const planCliCopies = ({ cliName, destRoot, }) => {
|
|
2897
|
+
const subs = { __CLI_NAME__: cliName };
|
|
2898
|
+
const base = path.join(destRoot, 'src', 'cli', cliName);
|
|
2899
|
+
return [
|
|
2900
|
+
{
|
|
2901
|
+
src: path.join(TEMPLATES_ROOT, 'cli', 'ts', 'index.ts'),
|
|
2902
|
+
dest: path.join(base, 'index.ts'),
|
|
2903
|
+
subs,
|
|
2904
|
+
},
|
|
2905
|
+
{
|
|
2906
|
+
src: path.join(TEMPLATES_ROOT, 'cli', 'ts', 'plugins', 'hello.ts'),
|
|
2907
|
+
dest: path.join(base, 'plugins', 'hello.ts'),
|
|
2908
|
+
subs,
|
|
2909
|
+
},
|
|
2910
|
+
];
|
|
974
2911
|
};
|
|
975
2912
|
|
|
976
2913
|
/**
|
|
977
|
-
*
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
defaults.log = options.log;
|
|
999
|
-
if (options.scripts !== undefined)
|
|
1000
|
-
defaults.scripts = options.scripts;
|
|
1001
|
-
if (options.shell !== undefined)
|
|
1002
|
-
defaults.shell = options.shell;
|
|
1003
|
-
const ctx = {
|
|
1004
|
-
logger: options.logger,
|
|
1005
|
-
defaults,
|
|
1006
|
-
...(options.preHook ? { preHook: options.preHook } : {}),
|
|
1007
|
-
...(options.postHook ? { postHook: options.postHook } : {}),
|
|
1008
|
-
};
|
|
1009
|
-
program.hook('preSubcommand', makePreSubcommandHook(ctx));
|
|
1010
|
-
return program;
|
|
2914
|
+
* Determine whether the current environment should be treated as non-interactive.
|
|
2915
|
+
* CI heuristics include: CI, GITHUB_ACTIONS, BUILDKITE, TEAMCITY_VERSION, TF_BUILD.
|
|
2916
|
+
*/
|
|
2917
|
+
const isNonInteractive = () => {
|
|
2918
|
+
const ciLike = process.env.CI ||
|
|
2919
|
+
process.env.GITHUB_ACTIONS ||
|
|
2920
|
+
process.env.BUILDKITE ||
|
|
2921
|
+
process.env.TEAMCITY_VERSION ||
|
|
2922
|
+
process.env.TF_BUILD;
|
|
2923
|
+
return Boolean(ciLike) || !(stdin.isTTY && stdout.isTTY);
|
|
2924
|
+
};
|
|
2925
|
+
const promptDecision = async (filePath, logger, rl) => {
|
|
2926
|
+
logger.log(`File exists: ${filePath}\nChoose: [o]verwrite, [e]xample, [s]kip, [O]verwrite All, [E]xample All, [S]kip All`);
|
|
2927
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
2928
|
+
while (true) {
|
|
2929
|
+
const a = (await rl.question('> ')).trim();
|
|
2930
|
+
const valid = ['o', 'e', 's', 'O', 'E', 'S'];
|
|
2931
|
+
if (valid.includes(a))
|
|
2932
|
+
return a;
|
|
2933
|
+
logger.log('Please enter one of: o e s O E S');
|
|
2934
|
+
}
|
|
1011
2935
|
};
|
|
1012
2936
|
|
|
1013
|
-
|
|
1014
|
-
|
|
2937
|
+
/**
|
|
2938
|
+
* Requirements: Init scaffolding plugin with collision flow and CI detection.
|
|
2939
|
+
* Note: Large file scheduled for decomposition; tracked in stan.todo.md.
|
|
2940
|
+
*/
|
|
2941
|
+
const initPlugin = (opts = {}) => definePlugin({
|
|
2942
|
+
id: 'init',
|
|
2943
|
+
setup(cli) {
|
|
2944
|
+
const logger = opts.logger ?? console;
|
|
2945
|
+
const cmd = cli
|
|
2946
|
+
.ns('init')
|
|
2947
|
+
.description('Scaffold getdotenv config files and a host-based CLI skeleton.')
|
|
2948
|
+
.argument('[dest]', 'destination path (default: ./)', '.')
|
|
2949
|
+
.option('--config-format <format>', 'config format: json|yaml|js|ts', 'json')
|
|
2950
|
+
.option('--with-local', 'include .local config variant')
|
|
2951
|
+
.option('--dynamic', 'include dynamic examples (JS/TS configs)')
|
|
2952
|
+
.option('--cli-name <string>', 'CLI name for skeleton and tokens')
|
|
2953
|
+
.option('--force', 'overwrite all existing files')
|
|
2954
|
+
.option('--yes', 'skip all collisions (no overwrite)')
|
|
2955
|
+
.action(async (destArg) => {
|
|
2956
|
+
// Read options directly from the captured command instance.
|
|
2957
|
+
// Cast to a plain record to satisfy exact-optional and lint safety.
|
|
2958
|
+
const o = cmd.opts() ?? {};
|
|
2959
|
+
const destRel = typeof destArg === 'string' && destArg.length > 0 ? destArg : '.';
|
|
2960
|
+
const cwd = process.cwd();
|
|
2961
|
+
const destRoot = path.resolve(cwd, destRel);
|
|
2962
|
+
const formatInput = o.configFormat;
|
|
2963
|
+
const formatRaw = typeof formatInput === 'string'
|
|
2964
|
+
? formatInput.toLowerCase()
|
|
2965
|
+
: 'json';
|
|
2966
|
+
const format = (['json', 'yaml', 'js', 'ts'].includes(formatRaw)
|
|
2967
|
+
? formatRaw
|
|
2968
|
+
: 'json');
|
|
2969
|
+
const withLocal = !!o.withLocal;
|
|
2970
|
+
// dynamic flag reserved for future template variants; present for UX compatibility
|
|
2971
|
+
void o.dynamic;
|
|
2972
|
+
// CLI name default: --cli-name | basename(dest) | 'mycli'
|
|
2973
|
+
const cliName = (typeof o.cliName === 'string' && o.cliName.length > 0
|
|
2974
|
+
? o.cliName
|
|
2975
|
+
: path.basename(destRoot) || 'mycli') || 'mycli';
|
|
2976
|
+
// Precedence: --force > --yes > auto-detect(non-interactive => yes)
|
|
2977
|
+
const force = !!o.force;
|
|
2978
|
+
const yes = !!o.yes || (!force && isNonInteractive());
|
|
2979
|
+
// Build copy plan
|
|
2980
|
+
const cfgCopies = planConfigCopies({ format, withLocal, destRoot });
|
|
2981
|
+
const cliCopies = planCliCopies({ cliName, destRoot });
|
|
2982
|
+
const copies = [...cfgCopies, ...cliCopies];
|
|
2983
|
+
// Interactive state
|
|
2984
|
+
let globalDecision;
|
|
2985
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
2986
|
+
try {
|
|
2987
|
+
for (const item of copies) {
|
|
2988
|
+
const exists = await fs.pathExists(item.dest);
|
|
2989
|
+
if (!exists) {
|
|
2990
|
+
const subs = item.subs ?? {};
|
|
2991
|
+
await copyTextFile(item.src, item.dest, subs);
|
|
2992
|
+
logger.log(`Created ${path.relative(cwd, item.dest)}`);
|
|
2993
|
+
continue;
|
|
2994
|
+
}
|
|
2995
|
+
// Collision
|
|
2996
|
+
if (force) {
|
|
2997
|
+
const subs = item.subs ?? {};
|
|
2998
|
+
await copyTextFile(item.src, item.dest, subs);
|
|
2999
|
+
logger.log(`Overwrote ${path.relative(cwd, item.dest)}`);
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
if (yes) {
|
|
3003
|
+
logger.log(`Skipped ${path.relative(cwd, item.dest)}`);
|
|
3004
|
+
continue;
|
|
3005
|
+
}
|
|
3006
|
+
let decision = globalDecision;
|
|
3007
|
+
if (!decision) {
|
|
3008
|
+
const a = await promptDecision(item.dest, logger, rl);
|
|
3009
|
+
if (a === 'O') {
|
|
3010
|
+
globalDecision = 'overwrite';
|
|
3011
|
+
decision = 'overwrite';
|
|
3012
|
+
}
|
|
3013
|
+
else if (a === 'E') {
|
|
3014
|
+
globalDecision = 'example';
|
|
3015
|
+
decision = 'example';
|
|
3016
|
+
}
|
|
3017
|
+
else if (a === 'S') {
|
|
3018
|
+
globalDecision = 'skip';
|
|
3019
|
+
decision = 'skip';
|
|
3020
|
+
}
|
|
3021
|
+
else {
|
|
3022
|
+
decision =
|
|
3023
|
+
a === 'o' ? 'overwrite' : a === 'e' ? 'example' : 'skip';
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
if (decision === 'overwrite') {
|
|
3027
|
+
const subs = item.subs ?? {};
|
|
3028
|
+
await copyTextFile(item.src, item.dest, subs);
|
|
3029
|
+
logger.log(`Overwrote ${path.relative(cwd, item.dest)}`);
|
|
3030
|
+
}
|
|
3031
|
+
else if (decision === 'example') {
|
|
3032
|
+
const destEx = `${item.dest}.example`;
|
|
3033
|
+
const subs = item.subs ?? {};
|
|
3034
|
+
await copyTextFile(item.src, destEx, subs);
|
|
3035
|
+
logger.log(`Wrote example ${path.relative(cwd, destEx)}`);
|
|
3036
|
+
}
|
|
3037
|
+
else {
|
|
3038
|
+
logger.log(`Skipped ${path.relative(cwd, item.dest)}`);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
// Ensure .gitignore includes local config patterns.
|
|
3042
|
+
const giPath = path.join(destRoot, '.gitignore');
|
|
3043
|
+
const { created, changed } = await ensureLines(giPath, [
|
|
3044
|
+
'getdotenv.config.local.*',
|
|
3045
|
+
'*.local',
|
|
3046
|
+
]);
|
|
3047
|
+
if (created) {
|
|
3048
|
+
logger.log(`Created ${path.relative(cwd, giPath)}`);
|
|
3049
|
+
}
|
|
3050
|
+
else if (changed) {
|
|
3051
|
+
logger.log(`Updated ${path.relative(cwd, giPath)}`);
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
finally {
|
|
3055
|
+
rl.close();
|
|
3056
|
+
}
|
|
3057
|
+
});
|
|
3058
|
+
},
|
|
1015
3059
|
});
|
|
3060
|
+
|
|
3061
|
+
// Shipped CLI rebased on plugin-first host.
|
|
3062
|
+
const program = new GetDotenvCli('getdotenv')
|
|
3063
|
+
.attachRootOptions({ loadProcess: false })
|
|
3064
|
+
.use(cmdPlugin({ asDefault: true, optionAlias: '-c, --cmd <command...>' }))
|
|
3065
|
+
.use(batchPlugin())
|
|
3066
|
+
.use(awsPlugin())
|
|
3067
|
+
.use(demoPlugin())
|
|
3068
|
+
.use(initPlugin())
|
|
3069
|
+
.passOptions({ loadProcess: false });
|
|
1016
3070
|
await program.parseAsync();
|