@sayknow-cli/utils 0.7.10 → 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.
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.ts CHANGED
@@ -1,23 +1,174 @@
1
+ import * as fs from "node:fs";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
- import { getAgentDir, getConfigRootDir } from "./dirs";
4
+ import { canonicalEnvKey, getAgentDir, getConfigRootDir, getTrustedHomeDir, pathIdentityKey } from "./dirs";
4
5
  import { isSafeEnvName, isSafeEnvValue } from "./spawn-env";
5
6
 
6
7
  export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
7
8
 
8
- import { parseEnvFile, parseShellEnvFile } from "./env-file";
9
+ import {
10
+ parseEnvFile,
11
+ parseEnvFileContent,
12
+ parseShellEnvFile,
13
+ projectEnvLayerFiles,
14
+ projectEnvSnapshot,
15
+ } from "./env-file";
9
16
 
10
17
  // Re-exported so the public surface of this module is unchanged.
11
18
  export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
12
19
 
20
+ function findClosingBrace(value: string, open: number): number {
21
+ let depth = 1;
22
+ for (let index = open + 2; index < value.length; index++) {
23
+ if (value[index] === "\\") {
24
+ index++;
25
+ continue;
26
+ }
27
+ if (value[index] === "$" && value[index + 1] === "{") {
28
+ depth++;
29
+ index++;
30
+ } else if (value[index] === "}" && --depth === 0) {
31
+ return index;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
37
+ function expandProjectEnvValue(value: string, resolve: (name: string) => string | undefined): string {
38
+ let result = "";
39
+ for (let index = 0; index < value.length; index++) {
40
+ const char = value[index];
41
+ if (char === "\\" && value[index + 1] === "$") {
42
+ result += "$";
43
+ index++;
44
+ continue;
45
+ }
46
+ if (char !== "$") {
47
+ result += char;
48
+ continue;
49
+ }
50
+
51
+ let name: string | undefined;
52
+ let end = index;
53
+ let fallback: string | undefined;
54
+ if (value[index + 1] === "{") {
55
+ const close = findClosingBrace(value, index);
56
+ const expression = close < 0 ? undefined : value.slice(index + 2, close);
57
+ const match = expression && /^([A-Za-z_][A-Za-z0-9_]*)(?::-(.*))?$/.exec(expression);
58
+ if (match) {
59
+ name = match[1];
60
+ fallback = match[2];
61
+ end = close;
62
+ }
63
+ } else {
64
+ const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(value.slice(index + 1));
65
+ if (match) {
66
+ name = match[0];
67
+ end = index + name.length;
68
+ }
69
+ }
70
+ if (!name) {
71
+ result += "$";
72
+ continue;
73
+ }
74
+ result += resolve(name) ?? (fallback === undefined ? "" : expandProjectEnvValue(fallback, resolve));
75
+ index = end;
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function loadProjectEnv(): {
81
+ values: Record<string, string>;
82
+ loadedValues: Record<string, string>;
83
+ dynamic: Set<string>;
84
+ } {
85
+ const cwd = process.cwd();
86
+ const nodeEnv = process.env.NODE_ENV || Bun.env.NODE_ENV;
87
+ // Same file list as projectEnvSnapshot, so a loaded value stays project-controlled.
88
+ const files = projectEnvLayerFiles(nodeEnv);
89
+ const values: Record<string, string> = {};
90
+ const dynamic = new Set<string>();
91
+ for (const file of files) {
92
+ const parsed = parseEnvFile(path.join(cwd, file));
93
+ for (const [rawKey, value] of Object.entries(parsed)) {
94
+ // Windows environment names are case-insensitive, so the guard lookups
95
+ // below must see the same key Bun loaded into `process.env`.
96
+ const key = canonicalEnvKey(rawKey);
97
+ values[key] = value;
98
+ // Track dynamic provenance only for the winning declaration.
99
+ if (/[$`]/.test(value)) dynamic.add(key);
100
+ else dynamic.delete(key);
101
+ }
102
+ }
103
+ // Quoted escapes are not `$` or backticks, but Bun autoload decodes `\n`
104
+ // and `\r` inside double quotes. `projectEnvSnapshot` already refuses that
105
+ // shape. Union those keys. Do not copy its values: an unparsed declaration
106
+ // is stored there as "" and must not overlay this layer. File selection
107
+ // stays above, so a higher static layer still clears a lower dynamic one.
108
+ for (const key of projectEnvSnapshot(cwd).dynamic) dynamic.add(key);
109
+
110
+ const resolved = new Map<string, string>();
111
+ const resolving = new Set<string>();
112
+ const resolveProjectValue = (key: string): string | undefined => {
113
+ const inherited = Bun.env[key];
114
+ if (inherited !== undefined) return inherited;
115
+ const cached = resolved.get(key);
116
+ if (cached !== undefined) return cached;
117
+ if (!Object.hasOwn(values, key) || resolving.has(key)) return undefined;
118
+
119
+ resolving.add(key);
120
+ const value = expandProjectEnvValue(values[key] ?? "", name => resolveProjectValue(canonicalEnvKey(name)));
121
+ resolving.delete(key);
122
+ resolved.set(key, value);
123
+ return value;
124
+ };
125
+ for (const [key, value] of Object.entries(values)) {
126
+ resolved.set(
127
+ key,
128
+ expandProjectEnvValue(value, name => resolveProjectValue(canonicalEnvKey(name))),
129
+ );
130
+ }
131
+ return { values, loadedValues: Object.fromEntries(resolved), dynamic };
132
+ }
133
+
13
134
  function resolveFileEnvValue(file: Record<string, string>, name: string): string | undefined {
14
135
  if (!isSafeEnvName(name)) return undefined;
15
- const value = file[name];
136
+ const value = file[canonicalEnvKey(name)];
16
137
  if (value === undefined || !isSafeEnvValue(value)) return undefined;
17
138
  const trimmed = value.trim();
18
139
  return trimmed.length > 0 ? trimmed : undefined;
19
140
  }
20
141
 
142
+ type TrustedAgentEnvRead =
143
+ | { status: "missing"; values: Record<string, string> }
144
+ | { status: "unavailable"; values: Record<string, string> }
145
+ | { status: "ok"; values: Record<string, string> };
146
+
147
+ function readTrustedAgentEnv(): TrustedAgentEnvRead {
148
+ let filePath: string;
149
+ try {
150
+ filePath = path.join(getAgentDir(), ".env");
151
+ } catch {
152
+ return { status: "unavailable", values: {} };
153
+ }
154
+ let fileDescriptor: number | undefined;
155
+ try {
156
+ const linkStats = fs.lstatSync(filePath);
157
+ if (!linkStats.isFile()) return { status: "unavailable", values: {} };
158
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
159
+ fileDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
160
+ const fileStats = fs.fstatSync(fileDescriptor);
161
+ if (!fileStats.isFile()) return { status: "unavailable", values: {} };
162
+ const content = fs.readFileSync(fileDescriptor, "utf-8");
163
+ return { status: "ok", values: parseEnvFileContent(content) };
164
+ } catch (error) {
165
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
166
+ return code === "ENOENT" ? { status: "missing", values: {} } : { status: "unavailable", values: {} };
167
+ } finally {
168
+ if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
169
+ }
170
+ }
171
+
21
172
  function filterCredentialInheritedEnv(env: Record<string, string | undefined>): Record<string, string> {
22
173
  const result: Record<string, string> = {};
23
174
  for (const key in env) {
@@ -25,34 +176,108 @@ function filterCredentialInheritedEnv(env: Record<string, string | undefined>):
25
176
  if (!isSafeEnvName(key) || value === undefined || !isSafeEnvValue(value)) continue;
26
177
 
27
178
  // Bun may have already loaded cwd/.env before JS runs. It does not expose the
28
- // source of each entry, so an exact match with projectEnv is ambiguous. Use
29
- // the safer credential rule: ambiguous project matches are excluded from the
30
- // credential-only inherited snapshot, while remaining available through $env.
179
+ // source of each entry, so a matching project declaration is ambiguous. A
180
+ // dynamic dotenv declaration is also ambiguous even when expansion changes
181
+ // its runtime value. Exclude those from the credential-only snapshot while
182
+ // keeping them available through $env.
31
183
  const projectValue = resolveFileEnvValue(projectEnv, key);
32
- if (projectValue !== undefined && projectValue === value) continue;
184
+ if (projectValue !== undefined && (projectSnapshot.dynamic.has(canonicalEnvKey(key)) || projectValue === value))
185
+ continue;
33
186
 
34
187
  result[key] = value;
35
188
  }
36
189
  return result;
37
190
  }
38
191
 
39
- // Eagerly parse the user's $HOME/.env and the current project's .env (from cwd)
40
- const homeShellEnv = {
41
- ...parseShellEnvFile(path.join(os.homedir(), ".zshenv")),
42
- ...parseShellEnvFile(path.join(os.homedir(), ".zprofile")),
43
- ...parseShellEnvFile(path.join(os.homedir(), ".zshrc")),
44
- ...parseShellEnvFile(path.join(os.homedir(), ".bash_profile")),
45
- ...parseShellEnvFile(path.join(os.homedir(), ".bashrc")),
46
- };
47
- const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
48
- const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
49
- const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
50
- const projectEnv = parseEnvFile(path.join(process.cwd(), ".env"));
192
+ // Parse the current project's .env first. Bun may have overlaid HOME from it
193
+ // before this module runs, so a declared HOME must never select user credential
194
+ // files for the credential-only snapshot.
195
+ const projectSnapshot = loadProjectEnv();
196
+ const projectEnv = projectSnapshot.values;
197
+
198
+ /** Project dotenv as it was when this module loaded, before a later `chdir` or file delete. */
199
+ export function startupProjectEnvSnapshot(): { values: Record<string, string>; dynamic: Set<string> } {
200
+ return { values: projectSnapshot.values, dynamic: projectSnapshot.dynamic };
201
+ }
202
+ const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
203
+ const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
204
+ const declaredHome = projectEnv[declaredHomeKey];
205
+ const runtimeHome = process.env[authoritativeHomeKey];
206
+ const rejectProjectHome =
207
+ declaredHome !== undefined &&
208
+ runtimeHome !== undefined &&
209
+ (projectSnapshot.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome);
210
+ let trustedEnvHome: string | undefined;
211
+ try {
212
+ trustedEnvHome = rejectProjectHome ? getTrustedHomeDir() : os.homedir();
213
+ } catch {
214
+ // No trustworthy account home means no user credential files are trusted.
215
+ trustedEnvHome = undefined;
216
+ }
217
+
218
+ // `path.resolve` does not follow symlinks, so macOS `/tmp` vs `/private/tmp`,
219
+ // a symlink home, or a bind mount compares unequal and would load the checkout
220
+ // as the trusted home. Keep the lexical check, then compare device/inode, then
221
+ // `pathIdentityKey` when the filesystem has no usable inode.
222
+ function credentialHomeMatchesCwd(home: string): boolean {
223
+ const cwd = process.cwd();
224
+ if (path.resolve(home) === path.resolve(cwd)) return true;
225
+ try {
226
+ const left = fs.statSync(home, { bigint: true });
227
+ const right = fs.statSync(cwd, { bigint: true });
228
+ if (left.isDirectory() && right.isDirectory() && left.ino !== 0n && right.ino !== 0n) {
229
+ return left.dev === right.dev && left.ino === right.ino;
230
+ }
231
+ } catch {
232
+ // Missing or unreadable: do not treat this directory as a credential home.
233
+ return true;
234
+ }
235
+ return pathIdentityKey(home) === pathIdentityKey(cwd);
236
+ }
237
+
238
+ // Eagerly parse the trusted user's env files and the project .env (from cwd).
239
+ // When cwd is that home, both the dotenv and the shell startup files are the
240
+ // checkout, so neither is a credential source.
241
+ const credentialHomeIsCwd = trustedEnvHome !== undefined && credentialHomeMatchesCwd(trustedEnvHome);
242
+ const homeShellEnv =
243
+ trustedEnvHome && !credentialHomeIsCwd
244
+ ? {
245
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".zshenv")),
246
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".zprofile")),
247
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".zshrc")),
248
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".bash_profile")),
249
+ ...parseShellEnvFile(path.join(trustedEnvHome, ".bashrc")),
250
+ }
251
+ : {};
252
+ const homeEnv = trustedEnvHome && !credentialHomeIsCwd ? parseEnvFile(path.join(trustedEnvHome, ".env")) : {};
253
+ let piEnv: Record<string, string> = {};
254
+ let agentEnv: Record<string, string> = {};
255
+ try {
256
+ piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
257
+ agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
258
+ } catch {
259
+ // Keep credential resolution fail-closed when trusted user state is unavailable.
260
+ }
261
+ const initialTrustedAgentEnv = readTrustedAgentEnv();
262
+ const projectLoadedEnv: Record<string, string | undefined> = Object.fromEntries(
263
+ Object.keys(projectEnv).map(key => [key, Bun.env[key]]),
264
+ );
51
265
 
52
266
  const inheritedEnv = filterCredentialInheritedEnv(Bun.env);
267
+ const rotatingAgentEnvNames = new Set(Object.keys(agentEnv));
53
268
 
54
269
  export function $inheritedEnv(name: string): string | undefined {
55
- return resolveFileEnvValue(inheritedEnv, name);
270
+ const snapshotValue = resolveFileEnvValue(inheritedEnv, name);
271
+ if (snapshotValue === undefined) return undefined;
272
+ // The snapshot records provenance — this key was inherited from the launching
273
+ // shell rather than the caller's cwd/.env — and pins the value so a later
274
+ // in-process write cannot swap the credential we authenticate with. It is not
275
+ // a cache that outlives the variable: once the key is removed from the live
276
+ // environment it is no longer inherited, so deletion is honoured. Without
277
+ // this, a credential present at import time can never be suppressed (tests
278
+ // that clear provider env vars silently keep resolving the real credential).
279
+ if (Bun.env[name] === undefined) return undefined;
280
+ return snapshotValue;
56
281
  }
57
282
 
58
283
  function resolveLiveCredentialEnvValue(name: string): string | undefined {
@@ -62,11 +287,12 @@ function resolveLiveCredentialEnvValue(name: string): string | undefined {
62
287
  const trimmed = value.trim();
63
288
  if (trimmed.length === 0) return undefined;
64
289
 
65
- const projectValue = resolveFileEnvValue(projectEnv, name);
66
290
  if (
67
- projectValue !== undefined &&
68
- projectValue === trimmed &&
69
- resolveFileEnvValue(inheritedEnv, name) === undefined
291
+ Object.hasOwn(projectEnv, canonicalEnvKey(name)) &&
292
+ resolveFileEnvValue(inheritedEnv, name) === undefined &&
293
+ (projectSnapshot.dynamic.has(canonicalEnvKey(name)) ||
294
+ trimmed === resolveFileEnvValue(projectEnv, name) ||
295
+ trimmed === projectLoadedEnv[canonicalEnvKey(name)])
70
296
  ) {
71
297
  return undefined;
72
298
  }
@@ -74,8 +300,44 @@ function resolveLiveCredentialEnvValue(name: string): string | undefined {
74
300
  return trimmed;
75
301
  }
76
302
 
77
- for (const file of [projectEnv, agentEnv, piEnv, homeEnv, homeShellEnv]) {
303
+ /**
304
+ * Names a repository `.env` must not install into the process environment.
305
+ * `NODE_TLS_REJECT_UNAUTHORIZED=0` disables certificate verification for every
306
+ * fetch in the process, so a project `.env` pairing it with a proxy would expose
307
+ * provider credentials to whoever answers the proxy.
308
+ * `SKC_HARNESS_SOCKET_DIR` and `SKC_HARNESS_ROOT_REGISTRY_DIR` select the harness
309
+ * control socket directory and the session-root registry. The harness reads both
310
+ * from the process environment after this module loads, so a project `.env` must
311
+ * not point either at a path the repository controls.
312
+ */
313
+ const PROJECT_DENIED_ENV_NAMES = [
314
+ "NODE_TLS_REJECT_UNAUTHORIZED",
315
+ "SKC_HARNESS_SOCKET_DIR",
316
+ "SKC_HARNESS_ROOT_REGISTRY_DIR",
317
+ ] as const;
318
+
319
+ // Bun may have loaded cwd/.env before JS runs. Drop a value the project declares
320
+ // (same provenance rule as the credential snapshot) and never copy it below.
321
+ // Removing `NODE_TLS_REJECT_UNAUTHORIZED` restores verification: Bun reads that
322
+ // switch per TLS connection. The same drop covers the harness directory names.
323
+ // A quoted escape is dynamic even when the line parser did not store a value.
324
+ for (const name of PROJECT_DENIED_ENV_NAMES) {
325
+ const key = canonicalEnvKey(name);
326
+ const declared = projectEnv[key];
327
+ if (declared === undefined && !projectSnapshot.dynamic.has(key)) continue;
328
+ const runtime = Bun.env[name];
329
+ if (
330
+ runtime !== undefined &&
331
+ (projectSnapshot.dynamic.has(key) || runtime === declared || runtime.trim() === declared)
332
+ ) {
333
+ delete Bun.env[name];
334
+ }
335
+ }
336
+ const projectDeniedKeys = new Set<string>(PROJECT_DENIED_ENV_NAMES.map(canonicalEnvKey));
337
+
338
+ for (const file of [projectSnapshot.loadedValues, agentEnv, piEnv, homeEnv, homeShellEnv]) {
78
339
  for (const key in file) {
340
+ if (file === projectSnapshot.loadedValues && projectDeniedKeys.has(key)) continue;
79
341
  if (!Bun.env[key]) {
80
342
  Bun.env[key] = file[key];
81
343
  }
@@ -126,6 +388,28 @@ export function $credentialEnv(name: string): string | undefined {
126
388
  );
127
389
  }
128
390
 
391
+ /**
392
+ * Resolve a credential that may rotate in the trusted agent `.env` while this
393
+ * process remains alive.
394
+ *
395
+ * Presence in the agent file establishes that file as the credential's
396
+ * authority, even when the launching shell inherited an older value. This is
397
+ * what lets an atomic token rotation repair a long-lived shell instead of
398
+ * falling back to its revoked snapshot. Removal is authoritative too, so a
399
+ * deleted token cannot silently reappear from that snapshot. Project `.env`
400
+ * files are never consulted; shell values retain their normal precedence for
401
+ * names the agent file does not own.
402
+ */
403
+ export function $rotatingCredentialEnv(name: string): string | undefined {
404
+ if (!isSafeEnvName(name)) return undefined;
405
+ const agentRead = readTrustedAgentEnv();
406
+ if (agentRead.status === "ok" && Object.hasOwn(agentRead.values, name)) rotatingAgentEnvNames.add(name);
407
+ if (initialTrustedAgentEnv.status === "unavailable" && !rotatingAgentEnvNames.has(name)) return undefined;
408
+ if (!rotatingAgentEnvNames.has(name)) return $credentialEnv(name);
409
+ if (agentRead.status !== "ok") return undefined;
410
+ return resolveFileEnvValue(agentRead.values, name);
411
+ }
412
+
129
413
  /**
130
414
  * Resolve the first credential env value from the given keys, excluding cwd/.env overlays.
131
415
  */
@@ -137,16 +421,20 @@ export function $pickCredentialEnv(...keys: string[]): string | undefined {
137
421
  return undefined;
138
422
  }
139
423
 
424
+ function parsePositiveInteger(raw: string | undefined): number | undefined {
425
+ const value = raw?.trim();
426
+ if (!value || !/^\d+$/.test(value)) return undefined;
427
+ const parsed = Number(value);
428
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
429
+ }
430
+
140
431
  /**
141
432
  * Parses a positive decimal integer from `$env[name]`.
142
- * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
433
+ * Empty, invalid, unsafe, zero, or negative values return `defaultValue`.
143
434
  */
144
435
  export function $envpos(name: string, defaultValue: number): number {
145
- const raw = $env[name];
146
- if (!raw) return defaultValue;
147
- const parsed = Number.parseInt(raw, 10);
148
- if (Number.isNaN(parsed) || parsed <= 0) return defaultValue;
149
- return parsed;
436
+ const parsed = parsePositiveInteger($env[name]);
437
+ return parsed ?? defaultValue;
150
438
  }
151
439
 
152
440
  /** True when `BUN_ENV` or `NODE_ENV` is the string `test`. */
@@ -190,10 +478,8 @@ export function $pickflag(...keys: string[]): boolean {
190
478
  /** Resolve the first positive integer among keys, else defaultValue (SKC-first). Set-but-invalid keys are skipped. */
191
479
  export function $pickenvpos(keys: string[], defaultValue: number): number {
192
480
  for (const key of keys) {
193
- const raw = $env[key]?.trim();
194
- if (!raw) continue;
195
- const parsed = Number.parseInt(raw, 10);
196
- if (!Number.isNaN(parsed) && parsed > 0) return parsed;
481
+ const parsed = parsePositiveInteger($env[key]);
482
+ if (parsed !== undefined) return parsed;
197
483
  }
198
484
  return defaultValue;
199
485
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cross-package classification for errors that describe an expected outcome
3
+ * rather than an unexpected failure.
4
+ *
5
+ * The symbol is registered globally so the marker survives package duplication
6
+ * and worker/VM boundaries without relying on a particular Error constructor.
7
+ */
8
+ export const DESIGNED_ERROR = Symbol.for("sayknow-cli.designed-error");
9
+
10
+ type MarkedError = object & { readonly [DESIGNED_ERROR]?: unknown };
11
+
12
+ /** Mark an Error as a designed outcome before it crosses package boundaries. */
13
+ export function markDesignedError<T extends Error>(error: T): T {
14
+ Object.defineProperty(error, DESIGNED_ERROR, {
15
+ configurable: false,
16
+ enumerable: false,
17
+ value: true,
18
+ writable: false,
19
+ });
20
+ return error;
21
+ }
22
+
23
+ /** Return true when a throwable carries the trusted designed-outcome marker. */
24
+ export function isDesignedError(error: unknown): boolean {
25
+ if ((typeof error !== "object" && typeof error !== "function") || error === null) return false;
26
+ try {
27
+ return (error as MarkedError)[DESIGNED_ERROR] === true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
@@ -81,15 +81,18 @@ export interface FetchWithRetryOptions extends RequestInit {
81
81
  /**
82
82
  * Per-delay cap. Server-provided `Retry-After` hints exceeding this return
83
83
  * the current response immediately — caller deals with the `!response.ok`.
84
- * Default `60_000`.
84
+ * Scheduled values are also capped at the platform timer ceiling. Default
85
+ * `60_000`.
85
86
  */
86
87
  maxDelayMs?: number;
87
88
  /**
88
89
  * Fallback delay schedule when no server hint is present. Number, array
89
90
  * (indexed by attempt, clamped to last), or function. Default exponential
90
- * `500ms * 2 ** attempt` capped at `maxDelayMs`.
91
+ * `500ms * 2 ** attempt` capped at `maxDelayMs` and the platform timer
92
+ * ceiling. Values that remain negative or non-finite after capping retry
93
+ * immediately.
91
94
  */
92
- defaultDelayMs?: number | readonly number[] | ((attempt: number) => number);
95
+ defaultDelayMs?: number | readonly number[] | ((attempt: number, error?: unknown) => number);
93
96
  /**
94
97
  * Optional per-attempt overlay merged into the base `RequestInit` each try.
95
98
  * Headers from the overlay shallow-merge over the base. Useful for auth
@@ -106,6 +109,9 @@ export interface FetchWithRetryOptions extends RequestInit {
106
109
 
107
110
  const DEFAULT_MAX_DELAY_MS = 60_000;
108
111
  const DEFAULT_MAX_ATTEMPTS = 5;
112
+ // Node-compatible timers coerce larger delays to 1 ms. Bun follows the same
113
+ // signed 32-bit boundary, so every scheduler input must stay at or below it.
114
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
109
115
 
110
116
  /**
111
117
  * Fetch with bounded retries and sensible defaults. Retries on any
@@ -142,7 +148,8 @@ export async function fetchWithRetry(
142
148
  if (signal?.aborted) throw new Error("Request was aborted");
143
149
  const wrapped = wrapNetworkError(error);
144
150
  if (attempt + 1 >= maxAttempts) throw wrapped;
145
- await scheduler.wait(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), { signal });
151
+ const delayMs = normalizeRetryDelay(resolveDefaultDelay(defaultDelayMs, attempt, error), maxDelayMs);
152
+ await scheduler.wait(delayMs, { signal });
146
153
  continue;
147
154
  }
148
155
 
@@ -152,7 +159,8 @@ export async function fetchWithRetry(
152
159
  const hint = extractRetryHint(response, await response.clone().text());
153
160
  if (hint !== undefined && hint > maxDelayMs) return response;
154
161
 
155
- const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs);
162
+ const delayMs = normalizeRetryDelay(hint ?? resolveDefaultDelay(defaultDelayMs, attempt), maxDelayMs);
163
+ void response.body?.cancel().catch(() => undefined);
156
164
  await scheduler.wait(delayMs, { signal });
157
165
  }
158
166
  }
@@ -186,12 +194,17 @@ function wrapNetworkError(error: unknown): Error {
186
194
  function resolveDefaultDelay(
187
195
  option: FetchWithRetryOptions["defaultDelayMs"],
188
196
  attempt: number,
189
- maxDelayMs: number,
197
+ error?: unknown,
190
198
  ): number {
191
- if (option === undefined) return Math.min(500 * 2 ** attempt, maxDelayMs);
192
- if (typeof option === "number") return Math.min(option, maxDelayMs);
193
- if (typeof option === "function") return Math.min(option(attempt), maxDelayMs);
194
- return Math.min(option[Math.min(attempt, option.length - 1)] ?? 0, maxDelayMs);
199
+ if (option === undefined) return 500 * 2 ** attempt;
200
+ if (typeof option === "number") return option;
201
+ if (typeof option === "function") return option(attempt, error);
202
+ return option[Math.min(attempt, option.length - 1)] ?? 0;
203
+ }
204
+
205
+ function normalizeRetryDelay(delayMs: number, maxDelayMs: number): number {
206
+ const cappedDelayMs = Math.min(delayMs, maxDelayMs, MAX_TIMER_DELAY_MS);
207
+ return Number.isFinite(cappedDelayMs) && cappedDelayMs >= 0 ? cappedDelayMs : 0;
195
208
  }
196
209
 
197
210
  /**
@@ -2,8 +2,29 @@ import { YAML } from "bun";
2
2
  import { truncate } from "./format";
3
3
  import * as logger from "./logger";
4
4
 
5
+ const HTML_COMMENT_OPEN = "<!--";
6
+ const HTML_COMMENT_CLOSE = "-->";
7
+
8
+ // One forward scan. `/<!--[\s\S]*?-->/g` retries every unterminated opener
9
+ // against the rest of the input, which is quadratic in the opener count.
10
+ // An opener with no later close does not match, so that tail stays unchanged.
5
11
  function stripHtmlComments(content: string): string {
6
- return content.replace(/<!--[\s\S]*?-->/g, "");
12
+ const parts: string[] = [];
13
+ let from = 0;
14
+ let i = 0;
15
+ const n = content.length;
16
+ while (i < n) {
17
+ const start = content.indexOf(HTML_COMMENT_OPEN, i);
18
+ if (start < 0) break;
19
+ const end = content.indexOf(HTML_COMMENT_CLOSE, start + HTML_COMMENT_OPEN.length);
20
+ if (end < 0) break;
21
+ if (start > from) parts.push(content.slice(from, start));
22
+ from = end + HTML_COMMENT_CLOSE.length;
23
+ i = from;
24
+ }
25
+ if (from === 0) return content;
26
+ if (from < n) parts.push(content.slice(from));
27
+ return parts.join("");
7
28
  }
8
29
 
9
30
  function stripLooseScalarTrailingCommas(metadata: string): string {
@@ -69,7 +90,12 @@ function normalizeKeys<T>(obj: T): T {
69
90
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
70
91
  const nk = key.includes("-") ? kebabToCamel(key) : key;
71
92
  const nv = normalizeKeys(value);
72
- result[nk] = nv;
93
+ // Assignment would route "__proto__" through Object.prototype's setter.
94
+ if (nk === "__proto__") {
95
+ Object.defineProperty(result, nk, { value: nv, writable: true, enumerable: true, configurable: true });
96
+ } else {
97
+ result[nk] = nv;
98
+ }
73
99
  if (nk !== key || nv !== value) changed = true;
74
100
  }
75
101
  return (changed ? result : obj) as T;
package/src/glob.ts CHANGED
@@ -137,6 +137,10 @@ export async function loadGitignorePatterns(baseDir: string): Promise<string[]>
137
137
  */
138
138
  export async function globPaths(patterns: string | string[], options: GlobPathsOptions = {}): Promise<string[]> {
139
139
  const { cwd, exclude, signal, timeoutMs, dot, onlyFiles = true, gitignore } = options;
140
+ const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
141
+ const combinedSignal =
142
+ signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal);
143
+ throwIfGlobAborted(combinedSignal);
140
144
 
141
145
  // Build exclude list: always exclude .git, exclude node_modules unless pattern references it
142
146
  const patternArray = Array.isArray(patterns) ? patterns : [patterns];
@@ -147,6 +151,7 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
147
151
 
148
152
  if (gitignore) {
149
153
  const gitignorePatterns = await loadGitignorePatterns(cwd ?? getProjectDir());
154
+ throwIfGlobAborted(combinedSignal);
150
155
  effectiveExclude = [...effectiveExclude, ...gitignorePatterns];
151
156
  }
152
157
 
@@ -158,11 +163,6 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
158
163
  // file; dedupe so a path is returned at most once regardless of pattern overlap.
159
164
  const seen = new Set<string>();
160
165
 
161
- // Combine timeout and abort signals
162
- const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
163
- const combinedSignal =
164
- signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal);
165
-
166
166
  for (const pattern of patternArray) {
167
167
  const glob = new Glob(pattern);
168
168
  const scanOptions = {
@@ -173,11 +173,7 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
173
173
  };
174
174
 
175
175
  for await (const entry of glob.scan(scanOptions)) {
176
- if (combinedSignal?.aborted) {
177
- const reason = combinedSignal.reason;
178
- if (reason instanceof Error) throw reason;
179
- throw new DOMException("Aborted", "AbortError");
180
- }
176
+ throwIfGlobAborted(combinedSignal);
181
177
 
182
178
  // Check exclusion patterns
183
179
  const normalized = entry.replace(/\\/g, "/");
@@ -190,7 +186,14 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
190
186
  seen.add(normalized);
191
187
  allResults.push(normalized);
192
188
  }
189
+ throwIfGlobAborted(combinedSignal);
193
190
  }
194
191
 
195
192
  return allResults;
196
193
  }
194
+
195
+ function throwIfGlobAborted(signal: AbortSignal | undefined): void {
196
+ if (!signal?.aborted) return;
197
+ if (signal.reason instanceof Error) throw signal.reason;
198
+ throw new DOMException("Aborted", "AbortError");
199
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Sanitize a value destined for an HTTP header.
3
+ *
4
+ * OS-derived components (kernel release, hostname, os.version) can contain
5
+ * non-ASCII characters — e.g. Android kernel releases such as
6
+ * `4.4.302-Minimal™-EAS-QTI_Haptic-R26` — which `Headers`/`fetch` reject
7
+ * before the request is ever sent. Strip everything outside printable
8
+ * ASCII so header construction can never throw on runtime-derived values.
9
+ */
10
+
11
+ const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
12
+
13
+ export function sanitizeHeaderComponent(value: string): string {
14
+ return value.replace(NON_PRINTABLE_ASCII, "");
15
+ }