@karmaniverous/get-dotenv 6.2.4 → 6.3.0

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.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/dist/chunks/{AwsRestJsonProtocol-Bq1HE-Ln.mjs → AwsRestJsonProtocol-Dv5q8CFK.mjs} +2 -2
  3. package/dist/chunks/{createCli-BY6_cfZr.mjs → createCli-BSn6Be40.mjs} +6 -5
  4. package/dist/chunks/{externalDataInterceptor-CbsdEYa-.mjs → externalDataInterceptor-pqHO-Qmn.mjs} +2 -2
  5. package/dist/chunks/{getSSOTokenFromFile-hUSpR7Wf.mjs → getSSOTokenFromFile-otmZHSRV.mjs} +1 -1
  6. package/dist/chunks/{index-BpCF5UKx.mjs → index-B18W-ELX.mjs} +5 -4
  7. package/dist/chunks/{index-CeCufHlm.mjs → index-BNcKuiBy.mjs} +11 -10
  8. package/dist/chunks/{index-DyU5pKKi.mjs → index-Bi0RIILn.mjs} +5 -4
  9. package/dist/chunks/{index-Bc3h0a95.mjs → index-BqZ3PB6c.mjs} +6 -5
  10. package/dist/chunks/{index-Dp1Ip6Ra.mjs → index-C4Ac6feq.mjs} +10 -9
  11. package/dist/chunks/{index-cIunyiUQ.mjs → index-C6uLiKpC.mjs} +5 -4
  12. package/dist/chunks/{index-C_wqbTwI.mjs → index-CGg5wWCm.mjs} +6 -5
  13. package/dist/chunks/{index-c7zKtEuy.mjs → index-CXpZ0pei.mjs} +7 -6
  14. package/dist/chunks/{index-Cu7rdyqN.mjs → index-CYoFYXZv.mjs} +8 -7
  15. package/dist/chunks/{index-B5JKTBOL.mjs → index-DFNcs3pR.mjs} +7 -6
  16. package/dist/chunks/{index-BPYF6K_G.mjs → index-DLQEHTw4.mjs} +8 -7
  17. package/dist/chunks/{index-DWAtHEA-.mjs → index-DtRaL61T.mjs} +5 -4
  18. package/dist/chunks/{index-BEJFiHMX.mjs → index-eZMlmESW.mjs} +14 -14
  19. package/dist/chunks/{loadSso-w1eTVg0O.mjs → loadSso-CJ_XUhEj.mjs} +7 -6
  20. package/dist/chunks/{loader-DnhPeGfq.mjs → loader-CePOf74i.mjs} +1 -0
  21. package/dist/chunks/{overlayEnv-Bs2kVayG.mjs → overlayEnv-Bqh_kPGA.mjs} +2 -1
  22. package/dist/chunks/{parseKnownFiles-B9cDK21V.mjs → parseKnownFiles-B6x1cUmR.mjs} +1 -1
  23. package/dist/chunks/{readMergedOptions-Nt0TR7dX.mjs → readMergedOptions-DLBDzpXX.mjs} +5 -4
  24. package/dist/chunks/{resolveCliOptions-TFRzhB2c.mjs → resolveCliOptions-_qtsVxda.mjs} +2 -1
  25. package/dist/chunks/{sdk-stream-mixin-BZoJ5jy9.mjs → sdk-stream-mixin-DCdC70Up.mjs} +1 -1
  26. package/dist/chunks/{spawnEnv-CN8a7cNR.mjs → spawnEnv-CQwFu7ZJ.mjs} +2 -1
  27. package/dist/chunks/{types-DJ-BGABd.mjs → types-DdqcXCV1.mjs} +1 -1
  28. package/dist/cli.mjs +9 -9
  29. package/dist/cliHost.mjs +7 -6
  30. package/dist/config.mjs +2 -1
  31. package/dist/env-overlay.mjs +2 -1
  32. package/dist/getdotenv.cli.mjs +9 -9
  33. package/dist/index.d.ts +485 -2
  34. package/dist/index.mjs +703 -11
  35. package/dist/plugins-aws.mjs +5 -4
  36. package/dist/plugins-batch.mjs +5 -4
  37. package/dist/plugins-cmd.mjs +7 -6
  38. package/dist/plugins-init.mjs +4 -4
  39. package/dist/plugins.mjs +8 -8
  40. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,27 +1,28 @@
1
- export { c as createCli } from './chunks/createCli-BY6_cfZr.mjs';
2
- export { G as GetDotenvCli, e as baseRootOptionDefaults, a as defineDynamic, b as defineGetDotenvConfig, d as definePlugin, g as getDotenv, c as getDotenvCliOptions2Options, i as interpolateDeep, m as maybeWarnEntropy, r as readMergedOptions, f as redactDisplay, h as redactObject } from './chunks/readMergedOptions-Nt0TR7dX.mjs';
3
- export { b as buildSpawnEnv, s as shouldCapture } from './chunks/spawnEnv-CN8a7cNR.mjs';
4
- export { d as defineScripts, g as groupPlugins } from './chunks/types-DJ-BGABd.mjs';
5
- import 'fs-extra';
1
+ export { c as createCli } from './chunks/createCli-BSn6Be40.mjs';
2
+ export { G as GetDotenvCli, b as baseRootOptionDefaults, e as defineDynamic, f as defineGetDotenvConfig, d as definePlugin, h as getDotenv, g as getDotenvCliOptions2Options, k as interpolateDeep, m as maybeWarnEntropy, r as readMergedOptions, i as redactDisplay, j as redactObject } from './chunks/readMergedOptions-DLBDzpXX.mjs';
3
+ export { d as buildSpawnEnv, s as shouldCapture } from './chunks/spawnEnv-CQwFu7ZJ.mjs';
4
+ export { d as defineScripts, g as groupPlugins } from './chunks/types-DdqcXCV1.mjs';
5
+ import fs from 'fs-extra';
6
6
  import 'crypto';
7
7
  import 'path';
8
8
  import 'url';
9
9
  export { z } from 'zod';
10
10
  import 'dotenv';
11
- export { t as traceChildEnv } from './chunks/index-Bc3h0a95.mjs';
12
- export { d as dotenvExpand, a as dotenvExpandAll, b as dotenvExpandFromProcessEnv } from './chunks/overlayEnv-Bs2kVayG.mjs';
13
- import './chunks/loader-DnhPeGfq.mjs';
11
+ export { t as traceChildEnv } from './chunks/index-BqZ3PB6c.mjs';
12
+ export { d as dotenvExpand, b as dotenvExpandAll, c as dotenvExpandFromProcessEnv } from './chunks/overlayEnv-Bqh_kPGA.mjs';
13
+ import path from 'node:path';
14
+ import './chunks/loader-CePOf74i.mjs';
14
15
  import 'package-directory';
15
16
  import 'yaml';
16
17
  import './chunks/loadModuleDefault-Dj8B3Stt.mjs';
17
18
  import 'nanoid';
18
19
  import 'execa';
19
20
  import './chunks/helpConfig-CGejgwWW.mjs';
20
- import './chunks/resolveCliOptions-TFRzhB2c.mjs';
21
+ import './chunks/resolveCliOptions-_qtsVxda.mjs';
21
22
  import './chunks/validate-CDl0rE6k.mjs';
22
23
  import './plugins-aws.mjs';
23
24
  import '@commander-js/extra-typings';
24
- import './chunks/index-CeCufHlm.mjs';
25
+ import './chunks/index-BNcKuiBy.mjs';
25
26
  import 'buffer';
26
27
  import 'os';
27
28
  import 'node:fs/promises';
@@ -36,5 +37,696 @@ import 'globby';
36
37
  import './plugins-init.mjs';
37
38
  import 'node:process';
38
39
  import 'readline/promises';
39
- import 'node:path';
40
40
  import 'node:url';
41
+
42
+ /**
43
+ * Dotenv editor types (format-preserving).
44
+ *
45
+ * Requirements addressed:
46
+ * - Provide a format-preserving dotenv edit surface (pure text pipeline + FS adapter).
47
+ * - Preserve comments/blank lines/unknown lines and separator spacing; support merge vs sync.
48
+ * - Deterministic target selection across getdotenv `paths` with optional template bootstrap.
49
+ *
50
+ * Notes:
51
+ * - The parser intentionally preserves unknown lines verbatim rather than rejecting them.
52
+ * - The editor focuses on `.env`-style KEY=VALUE lines; anything not recognized is preserved as raw text.
53
+ *
54
+ * @packageDocumentation
55
+ */
56
+ /**
57
+ * Narrow helper for internal use: detect own properties safely.
58
+ *
59
+ * @internal
60
+ */
61
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
62
+
63
+ /**
64
+ * Apply edits to a parsed dotenv document while preserving formatting.
65
+ *
66
+ * Requirements addressed:
67
+ * - Mode: merge vs sync.
68
+ * - Duplicate key strategy: all/first/last.
69
+ * - undefined behavior: skip (default).
70
+ * - null behavior: delete (default).
71
+ * - Quote preservation where safe; upgrade quoting when required (multiline, whitespace safety, inline comment safety).
72
+ *
73
+ * @packageDocumentation
74
+ */
75
+ const coerceToString = (v) => {
76
+ if (typeof v === 'string')
77
+ return v;
78
+ if (typeof v === 'number')
79
+ return String(v);
80
+ if (typeof v === 'boolean')
81
+ return v ? 'true' : 'false';
82
+ if (v === null || v === undefined)
83
+ return '';
84
+ return JSON.stringify(v);
85
+ };
86
+ const needsQuoted = (value, hasInlineSuffix) => {
87
+ if (value.includes('\n'))
88
+ return true;
89
+ if (value.includes('\r'))
90
+ return true;
91
+ // Preserve correctness: leading/trailing whitespace should be quoted.
92
+ if (/^\s|\s$/.test(value))
93
+ return true;
94
+ // Inline comment safety: if there is a suffix (comment) and the value contains '#', quote it.
95
+ if (hasInlineSuffix && value.includes('#'))
96
+ return true;
97
+ return false;
98
+ };
99
+ const escapeDoubleQuoted = (value) =>
100
+ // Minimal escaping: escape only `"`. (Do not force-escape backslashes.)
101
+ value.replace(/"/g, '\\"');
102
+ const toFileEol = (valueLf, fileEol) => fileEol === '\n' ? valueLf : valueLf.replace(/\n/g, '\r\n');
103
+ const renderValueToken = (args) => {
104
+ const { value, preferQuote, hasInlineSuffix, fileEol } = args;
105
+ const multiline = value.includes('\n');
106
+ if (multiline) {
107
+ // Multiline correctness: use double quotes.
108
+ return {
109
+ token: `"${toFileEol(escapeDoubleQuoted(value), fileEol)}"`,
110
+ quote: '"',
111
+ };
112
+ }
113
+ const mustQuote = needsQuoted(value, hasInlineSuffix);
114
+ if (!mustQuote && preferQuote === null)
115
+ return { token: value, quote: null };
116
+ // Try to preserve original quote style when safe.
117
+ if (preferQuote === "'" && !value.includes("'") && !mustQuote) {
118
+ return { token: `'${value}'`, quote: "'" };
119
+ }
120
+ if (preferQuote === "'" && !value.includes("'")) {
121
+ return { token: `'${value}'`, quote: "'" };
122
+ }
123
+ // Prefer double quotes as the general safe fallback.
124
+ return { token: `"${escapeDoubleQuoted(value)}"`, quote: '"' };
125
+ };
126
+ const pickIndexes = (idxs, strategy) => {
127
+ if (idxs.length === 0)
128
+ return [];
129
+ if (strategy === 'first') {
130
+ const first = idxs[0];
131
+ return first === undefined ? [] : [first];
132
+ }
133
+ if (strategy === 'last') {
134
+ const last = idxs[idxs.length - 1];
135
+ return last === undefined ? [] : [last];
136
+ }
137
+ return idxs.slice();
138
+ };
139
+ const segmentEndEol = (seg) => {
140
+ if ('eol' in seg)
141
+ return seg.eol;
142
+ // Raw segments store EOL inside raw; best-effort detect tail.
143
+ const raw = seg.raw;
144
+ if (raw.endsWith('\r\n'))
145
+ return '\r\n';
146
+ if (raw.endsWith('\n'))
147
+ return '\n';
148
+ return '';
149
+ };
150
+ const rebuildAssignmentRaw = (args) => {
151
+ const { seg, valueLf, fileEol } = args;
152
+ const { token, quote } = renderValueToken({
153
+ value: valueLf,
154
+ preferQuote: seg.quote,
155
+ hasInlineSuffix: seg.suffix.length > 0,
156
+ fileEol,
157
+ });
158
+ const line = `${seg.prefix}${seg.key}${seg.separator}${seg.valuePadding}${token}${seg.suffix}`;
159
+ const eol = segmentEndEol(seg);
160
+ const raw = eol ? line + eol : line;
161
+ return { ...seg, raw, value: valueLf, quote, eol };
162
+ };
163
+ const rebuildBareAsAssignment = (args) => {
164
+ const { seg, valueLf, fileEol, defaultSeparator } = args;
165
+ const { token, quote } = renderValueToken({
166
+ value: valueLf,
167
+ preferQuote: null,
168
+ hasInlineSuffix: seg.suffix.length > 0,
169
+ fileEol,
170
+ });
171
+ const separator = defaultSeparator;
172
+ const line = `${seg.prefix}${seg.key}${separator}${token}${seg.suffix}`;
173
+ const eol = segmentEndEol(seg);
174
+ const raw = eol ? line + eol : line;
175
+ return {
176
+ kind: 'assignment',
177
+ raw,
178
+ key: seg.key,
179
+ prefix: seg.prefix,
180
+ separator,
181
+ valuePadding: '',
182
+ quote,
183
+ value: valueLf,
184
+ suffix: seg.suffix,
185
+ eol,
186
+ };
187
+ };
188
+ const buildNewAssignmentAtEnd = (args) => {
189
+ const { key, valueLf, fileEol, defaultSeparator, endEol } = args;
190
+ const { token, quote } = renderValueToken({
191
+ value: valueLf,
192
+ preferQuote: null,
193
+ hasInlineSuffix: false,
194
+ fileEol,
195
+ });
196
+ const line = `${key}${defaultSeparator}${token}`;
197
+ const raw = endEol ? line + endEol : line;
198
+ return {
199
+ kind: 'assignment',
200
+ raw,
201
+ key,
202
+ prefix: '',
203
+ separator: defaultSeparator,
204
+ valuePadding: '',
205
+ quote,
206
+ value: valueLf,
207
+ suffix: '',
208
+ eol: endEol,
209
+ };
210
+ };
211
+ /**
212
+ * Apply a set of key/value updates to a parsed dotenv document.
213
+ *
214
+ * @param doc - Parsed dotenv document.
215
+ * @param updates - Key/value update map.
216
+ * @param opts - Editing options.
217
+ * @returns A new {@link DotenvDocument} with edits applied.
218
+ *
219
+ * @public
220
+ */
221
+ function applyDotenvEdits(doc, updates, opts) {
222
+ const mode = opts?.mode ?? 'merge';
223
+ const duplicateKeys = opts?.duplicateKeys ?? 'all';
224
+ const defaultSeparator = opts?.defaultSeparator ?? '=';
225
+ const segs = doc.segments.slice();
226
+ // Index key-bearing segments.
227
+ const byKey = new Map();
228
+ for (let i = 0; i < segs.length; i++) {
229
+ const s = segs[i];
230
+ if (!s)
231
+ continue;
232
+ if (s.kind === 'assignment' || s.kind === 'bare') {
233
+ const list = byKey.get(s.key) ?? [];
234
+ list.push(i);
235
+ byKey.set(s.key, list);
236
+ }
237
+ }
238
+ const deleteIdx = new Set();
239
+ const replace = new Map();
240
+ // Sync deletions: remove any existing key lines not present in update map (own props).
241
+ if (mode === 'sync') {
242
+ for (const [key, idxs] of byKey.entries()) {
243
+ if (!hasOwn(updates, key)) {
244
+ for (const i of idxs)
245
+ deleteIdx.add(i);
246
+ }
247
+ }
248
+ }
249
+ // Apply update-driven deletes and replacements.
250
+ for (const key of Object.keys(updates)) {
251
+ const v = updates[key];
252
+ const idxsAll = byKey.get(key) ?? [];
253
+ if (v === null) {
254
+ for (const i of pickIndexes(idxsAll, duplicateKeys))
255
+ deleteIdx.add(i);
256
+ continue;
257
+ }
258
+ if (v === undefined) {
259
+ // Note: in sync mode, this key counts as present (do not delete); replacements are skipped.
260
+ continue;
261
+ }
262
+ const valueLf = coerceToString(v);
263
+ for (const i of pickIndexes(idxsAll, duplicateKeys)) {
264
+ const seg = segs[i];
265
+ if (!seg)
266
+ continue;
267
+ if (seg.kind === 'assignment') {
268
+ replace.set(i, rebuildAssignmentRaw({ seg, valueLf, fileEol: doc.fileEol }));
269
+ }
270
+ else if (seg.kind === 'bare') {
271
+ replace.set(i, rebuildBareAsAssignment({
272
+ seg,
273
+ valueLf,
274
+ fileEol: doc.fileEol,
275
+ defaultSeparator,
276
+ }));
277
+ }
278
+ }
279
+ }
280
+ // Rebuild the segment list, applying deletions and replacements.
281
+ const out = [];
282
+ for (let i = 0; i < segs.length; i++) {
283
+ if (deleteIdx.has(i))
284
+ continue;
285
+ const repl = replace.get(i);
286
+ const original = segs[i];
287
+ if (repl)
288
+ out.push(repl);
289
+ else if (original)
290
+ out.push(original);
291
+ }
292
+ // Merge mode: append missing keys (only when provided with a concrete value).
293
+ if (mode === 'merge') {
294
+ const existingKeys = new Set();
295
+ for (const s of out) {
296
+ if (s.kind === 'assignment' || s.kind === 'bare')
297
+ existingKeys.add(s.key);
298
+ }
299
+ // Use the document EOL when inserting new lines; final newline presence is handled by render().
300
+ const endEol = doc.fileEol;
301
+ for (const key of Object.keys(updates)) {
302
+ if (existingKeys.has(key))
303
+ continue;
304
+ const v = updates[key];
305
+ if (v === undefined)
306
+ continue;
307
+ if (v === null)
308
+ continue;
309
+ const valueLf = coerceToString(v);
310
+ out.push(buildNewAssignmentAtEnd({
311
+ key,
312
+ valueLf,
313
+ fileEol: doc.fileEol,
314
+ defaultSeparator,
315
+ endEol,
316
+ }));
317
+ }
318
+ }
319
+ return { ...doc, segments: out };
320
+ }
321
+
322
+ /**
323
+ * Parse a dotenv file into a format-preserving document model.
324
+ *
325
+ * Requirements addressed:
326
+ * - Preserve comments, blank lines, ordering, and unknown lines verbatim.
327
+ * - Preserve separator spacing around `=`.
328
+ * - Support multiline quoted values (double/single quotes) by grouping physical lines.
329
+ *
330
+ * @packageDocumentation
331
+ */
332
+ const detectFileEol = (txt) => txt.includes('\r\n') ? '\r\n' : '\n';
333
+ const endsWithNewline$1 = (txt) => txt.endsWith('\n') || txt.endsWith('\r\n');
334
+ const splitLinesWithEol = (txt) => {
335
+ const out = [];
336
+ let start = 0;
337
+ for (let i = 0; i < txt.length; i++) {
338
+ const ch = txt.charAt(i);
339
+ if (ch !== '\n')
340
+ continue;
341
+ const isCrlf = i > 0 && txt.charAt(i - 1) === '\r';
342
+ const line = txt.slice(start, isCrlf ? i - 1 : i);
343
+ out.push({ line, eol: isCrlf ? '\r\n' : '\n' });
344
+ start = i + 1;
345
+ }
346
+ if (start < txt.length) {
347
+ out.push({ line: txt.slice(start), eol: '' });
348
+ }
349
+ else if (txt.length === 0) {
350
+ out.push({ line: '', eol: '' });
351
+ }
352
+ return out;
353
+ };
354
+ const normalizeInnerNewlinesToLf = (v) => v.replace(/\r\n/g, '\n');
355
+ const findFirstNonWhitespace = (s) => {
356
+ for (let i = 0; i < s.length; i++) {
357
+ if (!/\s/.test(s.charAt(i)))
358
+ return i;
359
+ }
360
+ return -1;
361
+ };
362
+ const splitInlineCommentUnquoted = (rhs) => {
363
+ // Match a `#` that is preceded by whitespace; keep all whitespace before `#` with the suffix.
364
+ for (let i = 0; i < rhs.length; i++) {
365
+ const ch = rhs.charAt(i);
366
+ if (ch !== '#')
367
+ continue;
368
+ if (i === 0)
369
+ return { value: '', suffix: rhs };
370
+ const prev = rhs.charAt(i - 1);
371
+ if (!/\s/.test(prev))
372
+ continue;
373
+ let j = i;
374
+ while (j > 0 && /\s/.test(rhs.charAt(j - 1)))
375
+ j--;
376
+ return { value: rhs.slice(0, j), suffix: rhs.slice(j) };
377
+ }
378
+ return { value: rhs, suffix: '' };
379
+ };
380
+ const tryParseQuotedValueAcrossLines = (lines, startIndex, startRhs, quote) => {
381
+ // Parse the quoted value beginning at the first quote in startRhs.
382
+ // Returns null if a closing quote is never found (to avoid corrupting segmentation).
383
+ let idx = startIndex;
384
+ let rhs = startRhs;
385
+ const firstQuoteIdx = rhs.indexOf(quote);
386
+ if (firstQuoteIdx < 0)
387
+ return null;
388
+ // Value text begins after the opening quote.
389
+ let cursor = firstQuoteIdx + 1;
390
+ let escaped = false;
391
+ let valueOut = '';
392
+ while (idx < lines.length) {
393
+ for (; cursor < rhs.length; cursor++) {
394
+ const ch = rhs.charAt(cursor);
395
+ if (escaped) {
396
+ valueOut += ch;
397
+ escaped = false;
398
+ continue;
399
+ }
400
+ if (ch === '\\') {
401
+ // Preserve backslashes; caller may choose to re-escape on render.
402
+ valueOut += ch;
403
+ escaped = true;
404
+ continue;
405
+ }
406
+ if (ch === quote) {
407
+ // Closing quote: suffix is the remainder of this physical line after the closing quote.
408
+ return {
409
+ endIndex: idx,
410
+ value: normalizeInnerNewlinesToLf(valueOut),
411
+ suffix: rhs.slice(cursor + 1),
412
+ };
413
+ }
414
+ valueOut += ch;
415
+ }
416
+ // Move to next physical line; insert a newline into the value.
417
+ idx += 1;
418
+ if (idx >= lines.length)
419
+ break;
420
+ // If the file ended without a newline, we still treat the join as a newline inside the quoted value.
421
+ valueOut += '\n';
422
+ const next = lines[idx];
423
+ if (!next)
424
+ break;
425
+ rhs = next.line;
426
+ cursor = 0;
427
+ escaped = false;
428
+ }
429
+ return null;
430
+ };
431
+ const parseKeyPrefix = (line) => {
432
+ // Allow: indentation + optional "export " + KEY
433
+ // KEY must be a shell-ish identifier.
434
+ const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(.*)$/.exec(line);
435
+ if (!m)
436
+ return null;
437
+ const prefix = m[1] ?? '';
438
+ const key = m[2] ?? '';
439
+ const rest = m[3] ?? '';
440
+ if (!key)
441
+ return null;
442
+ return { prefix, key, rest };
443
+ };
444
+ /**
445
+ * Parse dotenv text into a document model that preserves formatting.
446
+ *
447
+ * @param text - Dotenv file contents as UTF-8 text.
448
+ * @returns A parsed {@link DotenvDocument}.
449
+ *
450
+ * @public
451
+ */
452
+ function parseDotenvDocument(text) {
453
+ const fileEol = detectFileEol(text);
454
+ const trailingNewline = endsWithNewline$1(text);
455
+ const lines = splitLinesWithEol(text);
456
+ const segments = [];
457
+ for (let i = 0; i < lines.length; i++) {
458
+ const cur = lines[i];
459
+ if (!cur)
460
+ continue;
461
+ const line = cur.line;
462
+ const eol = cur.eol;
463
+ const parsed = parseKeyPrefix(line);
464
+ if (!parsed) {
465
+ const rawSeg = { kind: 'raw', raw: line + eol };
466
+ segments.push(rawSeg);
467
+ continue;
468
+ }
469
+ const { prefix, key, rest } = parsed;
470
+ const mAssign = /^(\s*=\s*)(.*)$/.exec(rest);
471
+ if (mAssign) {
472
+ const separator = mAssign[1] ?? '=';
473
+ const rhs = mAssign[2] ?? '';
474
+ // Preserve whitespace between separator and first token.
475
+ const nonWs = findFirstNonWhitespace(rhs);
476
+ const valuePadding = nonWs >= 0 ? rhs.slice(0, nonWs) : rhs;
477
+ const tokenStart = nonWs >= 0 ? rhs.charAt(nonWs) : '';
478
+ // Try quoted parsing first (single-line or multiline).
479
+ if (tokenStart === '"' || tokenStart === "'") {
480
+ const quote = tokenStart === '"' ? '"' : "'";
481
+ const parsedQuoted = tryParseQuotedValueAcrossLines(lines, i, rhs, quote);
482
+ if (parsedQuoted) {
483
+ const endIndex = parsedQuoted.endIndex;
484
+ const raw = lines
485
+ .slice(i, endIndex + 1)
486
+ .map((l) => l.line + l.eol)
487
+ .join('');
488
+ const endEol = lines[endIndex]?.eol ?? '';
489
+ const seg = {
490
+ kind: 'assignment',
491
+ raw,
492
+ key,
493
+ prefix,
494
+ separator,
495
+ valuePadding,
496
+ quote,
497
+ value: parsedQuoted.value,
498
+ suffix: parsedQuoted.suffix,
499
+ eol: endEol,
500
+ };
501
+ segments.push(seg);
502
+ i = endIndex;
503
+ continue;
504
+ }
505
+ // If quote is unclosed, preserve the line verbatim as raw.
506
+ // This avoids accidentally “parsing” malformed lines and losing formatting.
507
+ const rawSeg = { kind: 'raw', raw: line + eol };
508
+ segments.push(rawSeg);
509
+ continue;
510
+ }
511
+ // Unquoted single-line value: split inline comments.
512
+ const { value, suffix } = splitInlineCommentUnquoted(rhs);
513
+ const seg = {
514
+ kind: 'assignment',
515
+ raw: line + eol,
516
+ key,
517
+ prefix,
518
+ separator,
519
+ valuePadding,
520
+ quote: null,
521
+ value,
522
+ suffix,
523
+ eol,
524
+ };
525
+ segments.push(seg);
526
+ continue;
527
+ }
528
+ // Bare-key placeholder (KEY or KEY # comment). Only accept whitespace/comment in rest.
529
+ const mBare = /^(\s*)(#.*)?$/.exec(rest);
530
+ if (mBare) {
531
+ const suffix = (mBare[1] ?? '') + (mBare[2] ?? '');
532
+ const seg = {
533
+ kind: 'bare',
534
+ raw: line + eol,
535
+ key,
536
+ prefix,
537
+ suffix,
538
+ eol,
539
+ };
540
+ segments.push(seg);
541
+ continue;
542
+ }
543
+ // Unknown/unsupported syntax for this line: preserve verbatim.
544
+ const rawSeg = { kind: 'raw', raw: line + eol };
545
+ segments.push(rawSeg);
546
+ }
547
+ return { fileEol, trailingNewline, segments };
548
+ }
549
+
550
+ /**
551
+ * Render a parsed dotenv document back to text.
552
+ *
553
+ * Requirements addressed:
554
+ * - Preserve existing EOLs by default; support forcing LF/CRLF.
555
+ * - Preserve trailing newline presence/absence.
556
+ *
557
+ * @packageDocumentation
558
+ */
559
+ const normalizeEol = (txt, eol) => txt.replace(/\r?\n/g, eol);
560
+ const stripOneFinalNewline = (txt) => {
561
+ if (txt.endsWith('\r\n'))
562
+ return txt.slice(0, -2);
563
+ if (txt.endsWith('\n'))
564
+ return txt.slice(0, -1);
565
+ return txt;
566
+ };
567
+ const endsWithNewline = (txt) => txt.endsWith('\n') || txt.endsWith('\r\n');
568
+ /**
569
+ * Render a {@link DotenvDocument} to text.
570
+ *
571
+ * @param doc - Document to render.
572
+ * @param eolMode - EOL policy (`preserve` | `lf` | `crlf`).
573
+ * @returns Rendered dotenv text.
574
+ *
575
+ * @public
576
+ */
577
+ function renderDotenvDocument(doc, eolMode = 'preserve') {
578
+ const joined = doc.segments.map((s) => s.raw).join('');
579
+ const targetEol = eolMode === 'crlf' ? '\r\n' : eolMode === 'lf' ? '\n' : doc.fileEol;
580
+ const normalized = eolMode === 'preserve' ? joined : normalizeEol(joined, targetEol);
581
+ // Preserve final newline presence/absence.
582
+ if (doc.trailingNewline) {
583
+ return endsWithNewline(normalized) ? normalized : normalized + targetEol;
584
+ }
585
+ return stripOneFinalNewline(normalized);
586
+ }
587
+
588
+ /**
589
+ * High-level convenience API for editing dotenv text in memory.
590
+ *
591
+ * Requirements addressed:
592
+ * - Pure/text layer: parse → apply edits → render (no FS).
593
+ *
594
+ * @packageDocumentation
595
+ */
596
+ /**
597
+ * Edit dotenv text with format preservation.
598
+ *
599
+ * @param text - Existing dotenv text.
600
+ * @param updates - Update map of keys to values.
601
+ * @param options - Edit options (merge vs sync, duplicates, null/undefined behavior, EOL policy).
602
+ * @returns Updated dotenv text.
603
+ *
604
+ * @public
605
+ */
606
+ function editDotenvText(text, updates, options = {}) {
607
+ const doc = parseDotenvDocument(text);
608
+ const edited = applyDotenvEdits(doc, updates, {
609
+ ...(options.mode ? { mode: options.mode } : {}),
610
+ ...(options.duplicateKeys ? { duplicateKeys: options.duplicateKeys } : {}),
611
+ ...(options.undefinedBehavior
612
+ ? { undefinedBehavior: options.undefinedBehavior }
613
+ : {}),
614
+ ...(options.nullBehavior ? { nullBehavior: options.nullBehavior } : {}),
615
+ ...(typeof options.defaultSeparator === 'string'
616
+ ? { defaultSeparator: options.defaultSeparator }
617
+ : {}),
618
+ });
619
+ return renderDotenvDocument(edited, options.eol ?? 'preserve');
620
+ }
621
+
622
+ /**
623
+ * FS-level dotenv editor adapter (target resolution + template bootstrap).
624
+ *
625
+ * Requirements addressed:
626
+ * - Deterministic target selection across getdotenv `paths` only.
627
+ * - Scope axis (global|env) × privacy axis (public|private).
628
+ * - Template bootstrap: copy `<target>.<templateExtension>` to `<target>` when needed.
629
+ * - Edit in place while preserving formatting via the pure text editor.
630
+ *
631
+ * @packageDocumentation
632
+ */
633
+ const defaultFs = {
634
+ pathExists: async (p) => fs.pathExists(p),
635
+ readFile: async (p) => fs.readFile(p, 'utf-8'),
636
+ writeFile: async (p, contents) => fs.writeFile(p, contents, 'utf-8'),
637
+ copyFile: async (src, dest) => fs.copyFile(src, dest),
638
+ };
639
+ const resolveEnvName = (env, defaultEnv) => typeof env === 'string' && env.length > 0
640
+ ? env
641
+ : typeof defaultEnv === 'string' && defaultEnv.length > 0
642
+ ? defaultEnv
643
+ : undefined;
644
+ const buildTargetFilename = (opts) => {
645
+ const { dotenvToken, privateToken, scope, privacy, envName } = opts;
646
+ const parts = [dotenvToken];
647
+ if (scope === 'env') {
648
+ if (!envName) {
649
+ throw new Error(`Unable to resolve env-scoped dotenv filename: env is required.`);
650
+ }
651
+ parts.push(envName);
652
+ }
653
+ if (privacy === 'private')
654
+ parts.push(privateToken);
655
+ return parts.join('.');
656
+ };
657
+ const orderedPaths = (pathsIn, order) => {
658
+ const list = pathsIn.slice();
659
+ return order === 'forward' ? list : list.reverse();
660
+ };
661
+ const resolveTargetAcrossPaths = async (fsPort, opts) => {
662
+ const { filename, templateExtension, searchOrder } = opts;
663
+ const pathsOrdered = orderedPaths(opts.paths, searchOrder);
664
+ for (const dir of pathsOrdered) {
665
+ const targetPath = path.resolve(dir, filename);
666
+ if (await fsPort.pathExists(targetPath))
667
+ return { targetPath };
668
+ const templatePath = `${targetPath}.${templateExtension}`;
669
+ if (await fsPort.pathExists(templatePath))
670
+ return { targetPath, templatePath };
671
+ }
672
+ throw new Error(`Unable to locate dotenv target "${filename}" under provided paths, and no template was found.`);
673
+ };
674
+ /**
675
+ * Edit a dotenv file selected by scope/privacy across a list of search paths.
676
+ *
677
+ * @param updates - Update map of keys to values.
678
+ * @param options - Target selection options + edit options.
679
+ * @returns An {@link EditDotenvFileResult}.
680
+ *
681
+ * @public
682
+ */
683
+ async function editDotenvFile(updates, options) {
684
+ const fsPort = options.fs ?? defaultFs;
685
+ const dotenvToken = options.dotenvToken ?? '.env';
686
+ const privateToken = options.privateToken ?? 'local';
687
+ const templateExtension = options.templateExtension ?? 'template';
688
+ const searchOrder = options.searchOrder ?? 'reverse';
689
+ const envName = resolveEnvName(options.env, options.defaultEnv);
690
+ const filename = buildTargetFilename({
691
+ dotenvToken,
692
+ privateToken,
693
+ scope: options.scope,
694
+ privacy: options.privacy,
695
+ ...(envName ? { envName } : {}),
696
+ });
697
+ const resolved = await resolveTargetAcrossPaths(fsPort, {
698
+ paths: options.paths,
699
+ filename,
700
+ templateExtension,
701
+ searchOrder,
702
+ });
703
+ let createdFromTemplate = false;
704
+ if (resolved.templatePath) {
705
+ // Template bootstrap: copy template as the destination sibling.
706
+ // Only copy if the target does not already exist.
707
+ if (!(await fsPort.pathExists(resolved.targetPath))) {
708
+ await fsPort.copyFile(resolved.templatePath, resolved.targetPath);
709
+ createdFromTemplate = true;
710
+ }
711
+ }
712
+ const before = await fsPort.readFile(resolved.targetPath);
713
+ const after = editDotenvText(before, updates, {
714
+ ...(options.mode ? { mode: options.mode } : {}),
715
+ ...(options.duplicateKeys ? { duplicateKeys: options.duplicateKeys } : {}),
716
+ ...(options.undefinedBehavior
717
+ ? { undefinedBehavior: options.undefinedBehavior }
718
+ : {}),
719
+ ...(options.nullBehavior ? { nullBehavior: options.nullBehavior } : {}),
720
+ ...(options.eol ? { eol: options.eol } : {}),
721
+ ...(typeof options.defaultSeparator === 'string'
722
+ ? { defaultSeparator: options.defaultSeparator }
723
+ : {}),
724
+ });
725
+ const changed = before !== after;
726
+ if (changed) {
727
+ await fsPort.writeFile(resolved.targetPath, after);
728
+ }
729
+ return { path: resolved.targetPath, createdFromTemplate, changed };
730
+ }
731
+
732
+ export { applyDotenvEdits, editDotenvFile, editDotenvText, parseDotenvDocument, renderDotenvDocument };