@sayknow-cli/utils 0.7.11 → 0.7.13

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 (49) hide show
  1. package/dist/types/broken-pipe.d.ts +4 -3
  2. package/dist/types/canonical-log-dir.d.ts +20 -0
  3. package/dist/types/cli-metadata.d.ts +33 -0
  4. package/dist/types/cli.d.ts +12 -0
  5. package/dist/types/crash-fingerprint.d.ts +82 -0
  6. package/dist/types/crash-journal.d.ts +84 -0
  7. package/dist/types/crash-redaction.d.ts +20 -0
  8. package/dist/types/dirs.d.ts +127 -36
  9. package/dist/types/env-file.d.ts +59 -0
  10. package/dist/types/env.d.ts +19 -1
  11. package/dist/types/error-classification.d.ts +12 -0
  12. package/dist/types/fetch-retry.d.ts +6 -3
  13. package/dist/types/header-value.d.ts +10 -0
  14. package/dist/types/index.d.ts +6 -0
  15. package/dist/types/logger.d.ts +22 -8
  16. package/dist/types/native-process.d.ts +12 -0
  17. package/dist/types/procmgr.d.ts +1 -35
  18. package/dist/types/safe-error.d.ts +20 -0
  19. package/dist/types/sanitize-text.d.ts +10 -0
  20. package/dist/types/shell-config.d.ts +58 -0
  21. package/dist/types/stream.d.ts +2 -1
  22. package/dist/types/which.d.ts +1 -1
  23. package/package.json +2 -2
  24. package/src/broken-pipe.ts +16 -4
  25. package/src/canonical-log-dir.ts +59 -0
  26. package/src/cli-metadata.ts +74 -0
  27. package/src/cli.ts +30 -2
  28. package/src/crash-fingerprint.ts +294 -0
  29. package/src/crash-journal.ts +292 -0
  30. package/src/crash-redaction.ts +558 -0
  31. package/src/dirs.ts +583 -165
  32. package/src/env-file.ts +326 -20
  33. package/src/env.ts +321 -35
  34. package/src/error-classification.ts +31 -0
  35. package/src/fetch-retry.ts +23 -10
  36. package/src/frontmatter.ts +28 -2
  37. package/src/glob.ts +13 -10
  38. package/src/header-value.ts +15 -0
  39. package/src/index.ts +6 -0
  40. package/src/logger.ts +252 -97
  41. package/src/native-process.ts +19 -0
  42. package/src/postmortem.ts +1 -55
  43. package/src/procmgr.ts +16 -212
  44. package/src/ptree.ts +3 -2
  45. package/src/safe-error.ts +40 -0
  46. package/src/sanitize-text.ts +14 -0
  47. package/src/shell-config.ts +329 -0
  48. package/src/stream.ts +32 -1
  49. 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
- const content = fs.readFileSync(filePath, "utf-8");
89
- for (const line of content.split("\n")) {
90
- const trimmed = line.trim();
91
- // Skip comments and blank lines
92
- if (!trimmed || trimmed.startsWith("#")) continue;
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
- const eqIndex = trimmed.indexOf("=");
95
- if (eqIndex === -1) continue;
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
- const key = trimmed.slice(0, eqIndex).trim();
98
- if (!isValidEnvName(key)) continue;
145
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|:)\s*(.*)$/.exec(trimmed);
146
+ if (!match) continue;
99
147
 
100
- let value = trimmed.slice(eqIndex + 1).trim();
148
+ const key = match[1];
149
+ if (!isValidEnvName(key)) continue;
101
150
 
102
- // Remove surrounding quotes (" or ')
103
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
104
- value = value.slice(1, -1);
105
- }
106
- if (!isSafeEnvValue(value)) continue;
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
- result[key] = value;
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
+ }