@sayknow-cli/utils 0.7.11 → 0.7.12
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/dist/types/broken-pipe.d.ts +4 -3
- package/dist/types/canonical-log-dir.d.ts +20 -0
- package/dist/types/cli-metadata.d.ts +33 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/crash-fingerprint.d.ts +82 -0
- package/dist/types/crash-journal.d.ts +84 -0
- package/dist/types/crash-redaction.d.ts +20 -0
- package/dist/types/dirs.d.ts +127 -36
- package/dist/types/env-file.d.ts +59 -0
- package/dist/types/env.d.ts +19 -1
- package/dist/types/error-classification.d.ts +12 -0
- package/dist/types/fetch-retry.d.ts +6 -3
- package/dist/types/header-value.d.ts +10 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/logger.d.ts +22 -8
- package/dist/types/native-process.d.ts +12 -0
- package/dist/types/procmgr.d.ts +1 -35
- package/dist/types/safe-error.d.ts +20 -0
- package/dist/types/sanitize-text.d.ts +10 -0
- package/dist/types/shell-config.d.ts +58 -0
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/which.d.ts +1 -1
- package/package.json +2 -2
- package/src/broken-pipe.ts +16 -4
- package/src/canonical-log-dir.ts +59 -0
- package/src/cli-metadata.ts +74 -0
- package/src/cli.ts +30 -2
- package/src/crash-fingerprint.ts +294 -0
- package/src/crash-journal.ts +292 -0
- package/src/crash-redaction.ts +558 -0
- package/src/dirs.ts +583 -165
- package/src/env-file.ts +326 -20
- package/src/env.ts +321 -35
- package/src/error-classification.ts +31 -0
- package/src/fetch-retry.ts +23 -10
- package/src/frontmatter.ts +28 -2
- package/src/glob.ts +13 -10
- package/src/header-value.ts +15 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +252 -97
- package/src/native-process.ts +19 -0
- package/src/postmortem.ts +1 -55
- package/src/procmgr.ts +16 -212
- package/src/ptree.ts +3 -2
- package/src/safe-error.ts +40 -0
- package/src/sanitize-text.ts +14 -0
- package/src/shell-config.ts +329 -0
- package/src/stream.ts +32 -1
- package/src/which.ts +6 -5
package/src/env-file.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* below both of them.
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
9
10
|
import { isSafeEnvValue } from "./spawn-env";
|
|
10
11
|
|
|
11
12
|
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -38,6 +39,36 @@ function stripInlineShellComment(value: string): string {
|
|
|
38
39
|
return value.trimEnd();
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Strips an unquoted trailing `# comment` the way Bun's dotenv loader does.
|
|
44
|
+
* Outside quotes, `#` starts a comment even after a backslash (`a\#b` loads
|
|
45
|
+
* as `a\`). A backslash before a quote does not open a quoted span
|
|
46
|
+
* (`a\"b#c` loads as `a\"b`). Inside quotes, a backslash skips the next byte,
|
|
47
|
+
* so `#` and the closer stay in the value. Shell files use
|
|
48
|
+
* `stripInlineShellComment`, whose POSIX rule requires whitespace before `#`.
|
|
49
|
+
*/
|
|
50
|
+
function stripInlineDotenvComment(value: string): string {
|
|
51
|
+
let quote: '"' | "'" | undefined;
|
|
52
|
+
for (let i = 0; i < value.length; i++) {
|
|
53
|
+
const char = value[i];
|
|
54
|
+
const next = value[i + 1];
|
|
55
|
+
if (!quote && char === "\\" && (next === '"' || next === "'")) {
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (quote && char === "\\") {
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if ((char === '"' || char === "'") && (!quote || quote === char)) {
|
|
64
|
+
quote = quote ? undefined : char;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (char === "#" && !quote) return value.slice(0, i).trimEnd();
|
|
68
|
+
}
|
|
69
|
+
return value.trimEnd();
|
|
70
|
+
}
|
|
71
|
+
|
|
41
72
|
/**
|
|
42
73
|
* Parses simple POSIX shell environment assignments from files such as
|
|
43
74
|
* ~/.zshrc without executing user shell code. Supports `export KEY=value` and
|
|
@@ -81,35 +112,310 @@ export function parseShellEnvFile(filePath: string): Record<string, string> {
|
|
|
81
112
|
* Ignores lines that are empty or start with '#'. Trims whitespace.
|
|
82
113
|
* Allows values to be quoted with single or double quotes.
|
|
83
114
|
* Returns an object of key-value pairs.
|
|
115
|
+
*
|
|
116
|
+
* The trust guards (`trustedAgentDirOverrideFor`, `trustedConfigDirName`,
|
|
117
|
+
* `filterCredentialInheritedEnv`) decide provenance by comparing
|
|
118
|
+
* `process.env` against this parse, so the accepted syntax must be a superset
|
|
119
|
+
* of what Bun's own dotenv loader honors in `cwd/.env`: `export KEY=value`,
|
|
120
|
+
* whitespace around `=` or `:`, and `#` comments after unquoted values, including
|
|
121
|
+
* a backslash before `#` (quotes keep their `#`). Values that Bun would expand
|
|
122
|
+
* (`$VAR`, `${VAR}`, backticks,
|
|
123
|
+
* command substitution) are kept as their literal text: the trust rule only
|
|
124
|
+
* needs the parser to see the key at all, and an operator environment value
|
|
125
|
+
* cannot equal attacker-written expansion text, so a literal parse stays
|
|
126
|
+
* conservative.
|
|
84
127
|
*/
|
|
85
128
|
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
86
|
-
const result: Record<string, string> = {};
|
|
87
129
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
return parseEnvFileContent(fs.readFileSync(filePath, "utf-8"));
|
|
131
|
+
} catch {
|
|
132
|
+
// File doesn't exist or can't be read - return empty result
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
93
136
|
|
|
94
|
-
|
|
95
|
-
|
|
137
|
+
/** Parse dotenv content that has already been read from a trusted file. */
|
|
138
|
+
export function parseEnvFileContent(content: string): Record<string, string> {
|
|
139
|
+
const result: Record<string, string> = {};
|
|
140
|
+
for (const line of content.split("\n")) {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
// Skip comments and blank lines
|
|
143
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
96
144
|
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|:)\s*(.*)$/.exec(trimmed);
|
|
146
|
+
if (!match) continue;
|
|
99
147
|
|
|
100
|
-
|
|
148
|
+
const key = match[1];
|
|
149
|
+
if (!isValidEnvName(key)) continue;
|
|
101
150
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
151
|
+
// Strip an unquoted trailing `# comment` the way Bun's dotenv loader
|
|
152
|
+
// does (`KEY=v#note` loads as `v`); quoted `#` survives. A quoted value
|
|
153
|
+
// then ends at the first unescaped closer, as Bun 1.4 loads it; a suffix
|
|
154
|
+
// after the closer is classified dynamic below because Bun 1.3 keeps it.
|
|
155
|
+
let value = stripInlineDotenvComment(match[2] ?? "").trim();
|
|
156
|
+
value = dotenvQuotedLiteral(value);
|
|
157
|
+
if (!isSafeEnvValue(value)) continue;
|
|
107
158
|
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
} catch {
|
|
111
|
-
// File doesn't exist or can't be read - return empty result
|
|
159
|
+
result[key] = value;
|
|
112
160
|
}
|
|
113
161
|
|
|
114
162
|
return result;
|
|
115
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What the caller's checkout declares through its dotenv files.
|
|
167
|
+
*
|
|
168
|
+
* `values` is the merged declaration set, later layers winning. `dynamic` holds
|
|
169
|
+
* the keys whose surviving declaration is one Bun expands at load time, or whose
|
|
170
|
+
* raw text is quoted in a way this line parser does not decode. Provenance
|
|
171
|
+
* refuses those keys instead of comparing a value Bun may have loaded differently.
|
|
172
|
+
*/
|
|
173
|
+
export interface ProjectEnvSnapshot {
|
|
174
|
+
values: Record<string, string>;
|
|
175
|
+
dynamic: Set<string>;
|
|
176
|
+
/** True when at least one project dotenv layer could not be read safely. */
|
|
177
|
+
incomplete?: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ProjectEnvSnapshotOptions {
|
|
181
|
+
/** Maximum bytes read from each dotenv layer; unsafe files mark the snapshot incomplete. */
|
|
182
|
+
maxFileBytes?: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Windows environment variable names are case-insensitive, so a project dotenv
|
|
187
|
+
* line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
|
|
188
|
+
* provenance lookup is spelled in upper case, so the snapshot must be keyed the
|
|
189
|
+
* same way or the declaration is invisible to the guard while still being live
|
|
190
|
+
* in the process. POSIX names are case-sensitive and must not fold.
|
|
191
|
+
*/
|
|
192
|
+
export function canonicalEnvKey(name: string): string {
|
|
193
|
+
return process.platform === "win32" ? name.toUpperCase() : name;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Keys Bun's dotenv loader accepts that are not shell identifiers. Bun loads
|
|
198
|
+
* names containing `-` or `.` (e.g. `npm_config_node-options`, which npm reads as
|
|
199
|
+
* `node-options`), but the line parser keeps only identifiers, so provenance
|
|
200
|
+
* would never see them. They are refused as dynamic, like a dropped quoted key.
|
|
201
|
+
*/
|
|
202
|
+
function nonIdentifierDeclarationKeys(content: string): Set<string> {
|
|
203
|
+
const keys = new Set<string>();
|
|
204
|
+
for (const line of content.split("\n")) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
207
|
+
const key = /^(?:export\s+)?([A-Za-z0-9_.-]+)\s*(?:=|:)/.exec(trimmed)?.[1];
|
|
208
|
+
if (key && !isValidEnvName(key)) keys.add(canonicalEnvKey(key));
|
|
209
|
+
}
|
|
210
|
+
return keys;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether Bun 1.3 may continue this quoted value onto later physical lines.
|
|
215
|
+
*
|
|
216
|
+
* Bun 1.3 ends a quoted value at a closer followed only by whitespace or a `#`
|
|
217
|
+
* comment, scanning across newlines until it finds one. A line without such a
|
|
218
|
+
* closer can therefore swallow later `KEY=` lines, which the line parser still
|
|
219
|
+
* reads as declarations. A closer preceded by a backslash is not trusted.
|
|
220
|
+
*/
|
|
221
|
+
function quotedValueMayContinue(raw: string): boolean {
|
|
222
|
+
const value = raw.trimStart();
|
|
223
|
+
const opener = value[0];
|
|
224
|
+
if (opener !== '"' && opener !== "'" && opener !== "`") return false;
|
|
225
|
+
for (let i = 1; i < value.length; i++) {
|
|
226
|
+
if (value[i] !== opener || value[i - 1] === "\\") continue;
|
|
227
|
+
if (/^\s*(?:#.*)?$/.test(value.slice(i + 1))) return false;
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Keys whose last declaration is not safe to value-compare.
|
|
234
|
+
*
|
|
235
|
+
* The line parser drops the suffix after the first unescaped closing quote,
|
|
236
|
+
* matching Bun 1.4, and strips an unquoted `#` comment even when a backslash
|
|
237
|
+
* precedes the hash. Classification refuses a value that parser would not
|
|
238
|
+
* store the way Bun loaded it. Other backslashes stay in the compared text.
|
|
239
|
+
* The bytes inside the quotes are not decoded. Every declaration after a quoted
|
|
240
|
+
* value that may continue onto later lines is refused: Bun 1.3 may have read
|
|
241
|
+
* that line as part of the value and kept an earlier declaration of the key.
|
|
242
|
+
*/
|
|
243
|
+
function quotedDeclarationKeys(content: string): Set<string> {
|
|
244
|
+
const quoted = new Set<string>();
|
|
245
|
+
let continued = false;
|
|
246
|
+
for (const line of content.split("\n")) {
|
|
247
|
+
const trimmed = line.trim();
|
|
248
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
249
|
+
const wasContinued = continued;
|
|
250
|
+
// Any declaration Bun reads can open a value, including a name the line parser drops.
|
|
251
|
+
const separator = /[=:]/.exec(trimmed);
|
|
252
|
+
if (separator && quotedValueMayContinue(trimmed.slice(separator.index + 1))) continued = true;
|
|
253
|
+
// `.` stops at a carriage return, which is why the line parser drops the key.
|
|
254
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|:)\s*([\s\S]*)$/.exec(trimmed);
|
|
255
|
+
const key = match?.[1];
|
|
256
|
+
if (!key || !isValidEnvName(key)) continue;
|
|
257
|
+
const canonical = canonicalEnvKey(key);
|
|
258
|
+
const comparable = stripInlineDotenvComment(match[2] ?? "");
|
|
259
|
+
if (wasContinued || rawValueDisagreesWithLineParser(comparable)) quoted.add(canonical);
|
|
260
|
+
else quoted.delete(canonical);
|
|
261
|
+
}
|
|
262
|
+
return quoted;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Index of the first unescaped closer, or -1. A backslash skips the next character. */
|
|
266
|
+
function indexOfUnescapedCloser(value: string, opener: '"' | "'"): number {
|
|
267
|
+
for (let i = 1; i < value.length; i++) {
|
|
268
|
+
if (value[i] === "\\") {
|
|
269
|
+
i++;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (value[i] === opener) return i;
|
|
273
|
+
}
|
|
274
|
+
return -1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Bun 1.4 ends a quoted dotenv value at the first unescaped closer and drops
|
|
279
|
+
* the suffix (`KEY="v" ignored` loads as `v`). Bun 1.3 keeps that whole line
|
|
280
|
+
* text instead, so `rawValueDisagreesWithLineParser` refuses a suffixed value.
|
|
281
|
+
* Bytes inside the quotes are not decoded. An unclosed value keeps the
|
|
282
|
+
* previous surrounding-pair cut.
|
|
283
|
+
*/
|
|
284
|
+
function dotenvQuotedLiteral(value: string): string {
|
|
285
|
+
const opener = value[0];
|
|
286
|
+
if (opener !== '"' && opener !== "'") return value;
|
|
287
|
+
const closed = indexOfUnescapedCloser(value, opener);
|
|
288
|
+
if (closed !== -1) return value.slice(1, closed);
|
|
289
|
+
if (value.endsWith(opener)) return value.slice(1, -1);
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function rawValueDisagreesWithLineParser(raw: string): boolean {
|
|
294
|
+
const trimmed = raw.trim();
|
|
295
|
+
const opener = trimmed[0];
|
|
296
|
+
if (opener === '"' || opener === "'") {
|
|
297
|
+
const closed = indexOfUnescapedCloser(trimmed, opener);
|
|
298
|
+
if (closed !== -1) {
|
|
299
|
+
// Bun 1.3 (the runtime SKC ships on) loads `"v" x` as the raw text, while
|
|
300
|
+
// Bun 1.4 loads `v`. Neither form can be value-compared safely across both.
|
|
301
|
+
if (trimmed.slice(closed + 1).trim() !== "") return true;
|
|
302
|
+
const inner = trimmed.slice(1, closed);
|
|
303
|
+
if (inner.includes(opener) || inner.includes("`") || inner.includes("\r") || inner.includes("\n")) return true;
|
|
304
|
+
// Bun turns only `\n` and `\r` into controls inside double quotes.
|
|
305
|
+
if (opener === '"' && /\\[nr]/.test(inner)) return true;
|
|
306
|
+
// Provenance trims the snapshot and compares it with Bun's untrimmed runtime value.
|
|
307
|
+
if (inner !== inner.trim()) return true;
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return /["'`\r]/.test(trimmed);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The layered dotenv declarations Bun overlays into `process.env` for a cwd.
|
|
316
|
+
*
|
|
317
|
+
* Lives in this leaf module so every consumer shares ONE notion of provenance.
|
|
318
|
+
* `dirs.ts` resolves the config, agent and log directories from it, and
|
|
319
|
+
* `scripts/test-preload.ts` decides test isolation from it — a second, narrower
|
|
320
|
+
* reader (only `cwd/.env`, its own regex, its own dynamic test) is how a
|
|
321
|
+
* `SKC_LOG_DIR` declared in `.env.local` / `.env.$NODE_ENV` came to be honored
|
|
322
|
+
* by the preload and then rejected in production, silently routing test log
|
|
323
|
+
* records to the operator's canonical sink. This module imports only `node:fs`,
|
|
324
|
+
* `node:path` and the import-free `./spawn-env`, so importing it has no side
|
|
325
|
+
* effects and cannot freeze resolver state the way importing `dirs.ts` would.
|
|
326
|
+
*
|
|
327
|
+
* `.env.local` is deliberately skipped when `NODE_ENV === "test"`, matching the
|
|
328
|
+
* convention that a local override file is not part of a test run.
|
|
329
|
+
*/
|
|
330
|
+
/** Files Bun overlays for a cwd. Unset NODE_ENV selects the development layer. */
|
|
331
|
+
export function projectEnvLayerFiles(nodeEnv: string | undefined): string[] {
|
|
332
|
+
const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
|
|
333
|
+
const layer = validNodeEnv ?? (nodeEnv ? undefined : "development");
|
|
334
|
+
return [
|
|
335
|
+
".env",
|
|
336
|
+
...(layer ? [`.env.${layer}`] : []),
|
|
337
|
+
...(layer !== "test" ? [".env.local"] : []),
|
|
338
|
+
...(layer ? [`.env.${layer}.local`] : []),
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function readBoundedEnvFile(filePath: string, maxFileBytes: number): { content?: string; incomplete: boolean } {
|
|
343
|
+
let fileDescriptor: number | undefined;
|
|
344
|
+
try {
|
|
345
|
+
// Non-blocking open lets fstat reject FIFOs and device files before any read.
|
|
346
|
+
fileDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
347
|
+
const initialStat = fs.fstatSync(fileDescriptor);
|
|
348
|
+
if (!initialStat.isFile() || initialStat.size > maxFileBytes) return { incomplete: true };
|
|
349
|
+
|
|
350
|
+
const buffer = Buffer.allocUnsafe(maxFileBytes + 1);
|
|
351
|
+
let bytesRead = 0;
|
|
352
|
+
while (bytesRead < buffer.byteLength) {
|
|
353
|
+
const count = fs.readSync(fileDescriptor, buffer, bytesRead, buffer.byteLength - bytesRead, bytesRead);
|
|
354
|
+
if (count === 0) break;
|
|
355
|
+
bytesRead += count;
|
|
356
|
+
}
|
|
357
|
+
if (bytesRead > maxFileBytes || fs.fstatSync(fileDescriptor).size > maxFileBytes) {
|
|
358
|
+
return { incomplete: true };
|
|
359
|
+
}
|
|
360
|
+
return { content: buffer.subarray(0, bytesRead).toString("utf-8"), incomplete: false };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
363
|
+
return { incomplete: false };
|
|
364
|
+
}
|
|
365
|
+
return { incomplete: true };
|
|
366
|
+
} finally {
|
|
367
|
+
if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function projectEnvSnapshot(cwd = process.cwd(), options: ProjectEnvSnapshotOptions = {}): ProjectEnvSnapshot {
|
|
372
|
+
if (
|
|
373
|
+
options.maxFileBytes !== undefined &&
|
|
374
|
+
(!Number.isSafeInteger(options.maxFileBytes) || options.maxFileBytes < 0)
|
|
375
|
+
) {
|
|
376
|
+
throw new RangeError("maxFileBytes must be a non-negative safe integer");
|
|
377
|
+
}
|
|
378
|
+
const files = projectEnvLayerFiles(process.env.NODE_ENV);
|
|
379
|
+
const values: Record<string, string> = {};
|
|
380
|
+
const dynamic = new Set<string>();
|
|
381
|
+
let incomplete = false;
|
|
382
|
+
for (const file of files) {
|
|
383
|
+
const filePath = path.join(cwd, file);
|
|
384
|
+
let content: string;
|
|
385
|
+
if (options.maxFileBytes === undefined) {
|
|
386
|
+
try {
|
|
387
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
388
|
+
} catch {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
const boundedRead = readBoundedEnvFile(filePath, options.maxFileBytes);
|
|
393
|
+
if (boundedRead.incomplete) {
|
|
394
|
+
incomplete = true;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (boundedRead.content === undefined) continue;
|
|
398
|
+
content = boundedRead.content;
|
|
399
|
+
}
|
|
400
|
+
const quoted = quotedDeclarationKeys(content);
|
|
401
|
+
const seen = new Set<string>();
|
|
402
|
+
for (const [rawKey, value] of Object.entries(parseEnvFileContent(content))) {
|
|
403
|
+
const key = canonicalEnvKey(rawKey);
|
|
404
|
+
seen.add(key);
|
|
405
|
+
values[key] = value;
|
|
406
|
+
// A quoted declaration Bun may decode differently is refused, same as `$`.
|
|
407
|
+
// Unquoted values, and one plain pair of quotes, still compare by text.
|
|
408
|
+
if (quoted.has(key) || /[$`]/.test(value)) dynamic.add(key);
|
|
409
|
+
else dynamic.delete(key);
|
|
410
|
+
}
|
|
411
|
+
// The line parser drops a key when `.` cannot cross a carriage return, or
|
|
412
|
+
// when Bun accepts a non-identifier name. Keep that key in the snapshot so
|
|
413
|
+
// the value Bun loaded is not an override.
|
|
414
|
+
for (const key of [...quoted, ...nonIdentifierDeclarationKeys(content)]) {
|
|
415
|
+
if (seen.has(key)) continue;
|
|
416
|
+
values[key] = "";
|
|
417
|
+
dynamic.add(key);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return incomplete ? { values, dynamic, incomplete: true } : { values, dynamic };
|
|
421
|
+
}
|