@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/index.ts CHANGED
@@ -2,13 +2,18 @@ export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./broken-pipe";
4
4
  export * from "./color";
5
+ export * from "./crash-fingerprint";
6
+ export * from "./crash-journal";
7
+ export * from "./crash-redaction";
5
8
  export * from "./dirs";
6
9
  export * from "./env";
10
+ export * from "./error-classification";
7
11
  export * from "./fetch-retry";
8
12
  export * from "./format";
9
13
  export * from "./frontmatter";
10
14
  export * from "./fs-error";
11
15
  export * from "./glob";
16
+ export * from "./header-value";
12
17
  export * from "./hook-fetch";
13
18
  export * from "./json";
14
19
  export * as logger from "./logger";
@@ -20,6 +25,7 @@ export * as procmgr from "./procmgr";
20
25
  export * as prompt from "./prompt";
21
26
  export * as ptree from "./ptree";
22
27
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
28
+ export * from "./safe-error";
23
29
  export * from "./safe-stderr";
24
30
  export * from "./sanitize-text";
25
31
  export * from "./snowflake";
package/src/logger.ts CHANGED
@@ -11,15 +11,164 @@
11
11
  */
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import * as fs from "node:fs";
14
+ import * as path from "node:path";
14
15
  import type * as winston from "winston";
15
- import { getLogsDir } from "./dirs";
16
+ import { getEffectiveLogsDir } from "./dirs";
16
17
 
17
- /** Ensure a logs directory exists; return the resolved path. */
18
+ const LOG_DIR_MODE = 0o700;
19
+ const LOG_FILE_MODE = 0o600;
20
+
21
+ type LogPathPlatform = NodeJS.Platform | "posix";
22
+
23
+ function logPathKind(platform: LogPathPlatform): "posix" | "win32" {
24
+ return platform === "win32" ? "win32" : "posix";
25
+ }
26
+
27
+ function isLogSeparator(char: string, platform: LogPathPlatform): boolean {
28
+ return char === "/" || (logPathKind(platform) === "win32" && char === "\\");
29
+ }
30
+
31
+ /** A filesystem root. On POSIX a backslash is a file name, not a root or a separator. */
32
+ export function isFsRoot(target: string, platform: LogPathPlatform = process.platform): boolean {
33
+ if (logPathKind(platform) === "win32") {
34
+ return target === "/" || target === "\\" || /^[A-Za-z]:[\\/]$/.test(target);
35
+ }
36
+ if (target.length === 0) return false;
37
+ for (const char of target) {
38
+ if (char !== "/") return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ /**
44
+ * Drop trailing separators without changing the directory name.
45
+ * POSIX removes only `/`. Windows also removes `\`.
46
+ * A root keeps one separator so `C:\` does not become the drive-relative name `C:`.
47
+ */
48
+ export function lexicalLogPath(target: string, platform: LogPathPlatform = process.platform): string {
49
+ if (target.length === 0) return target;
50
+ let end = target.length;
51
+ while (end > 0 && isLogSeparator(target[end - 1] ?? "", platform)) {
52
+ const kept = target.slice(0, end);
53
+ const shorter = target.slice(0, end - 1);
54
+ if (isFsRoot(kept, platform) && !isFsRoot(shorter, platform)) break;
55
+ if (end === 1) break;
56
+ end -= 1;
57
+ }
58
+ return target.slice(0, end);
59
+ }
60
+
61
+ function pathFor(platform: LogPathPlatform): { normalize(target: string): string } {
62
+ return logPathKind(platform) === "win32" ? path.win32 : path.posix;
63
+ }
64
+
65
+ /** True when "." / ".." collapse to a filesystem root. Does not follow symlinks. */
66
+ export function resolvesAsFsRoot(target: string, platform: LogPathPlatform = process.platform): boolean {
67
+ const lexical = lexicalLogPath(target, platform);
68
+ if (isFsRoot(lexical, platform)) return true;
69
+ const collapsed = lexicalLogPath(pathFor(platform).normalize(lexical), platform);
70
+ return isFsRoot(collapsed, platform);
71
+ }
72
+
73
+ /** Skip mode repair for every spelling of a filesystem root. The transport path stays unchanged. */
74
+ function skipRootModeRepair(target: string): boolean {
75
+ const lexical = lexicalLogPath(target);
76
+ if (resolvesAsFsRoot(lexical) || resolvesAsFsRoot(path.resolve(lexical))) return true;
77
+ try {
78
+ return resolvesAsFsRoot(fs.realpathSync.native(lexical));
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** Drop a trailing "/." so "./link/." is judged as "./link". ".." is not collapsed. */
85
+ function pathForSymlinkCheck(target: string): string {
86
+ let current = lexicalLogPath(target);
87
+ const win = process.platform === "win32";
88
+ for (;;) {
89
+ const dot = current.endsWith("/.") || (win && current.endsWith("\\."));
90
+ if (!dot) return current;
91
+ current = lexicalLogPath(current.slice(0, -2));
92
+ }
93
+ }
94
+
95
+ function isFinalSymlink(target: string): boolean {
96
+ try {
97
+ return fs.lstatSync(pathForSymlinkCheck(target)).isSymbolicLink();
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /** True when this inode is the filesystem root. Unknown identity fails closed. */
104
+ function inodeIsFsRoot(st: fs.Stats): boolean {
105
+ if (process.platform === "win32") return false;
106
+ try {
107
+ const root = fs.statSync("/");
108
+ return st.dev === root.dev && st.ino === root.ino;
109
+ } catch {
110
+ return true;
111
+ }
112
+ }
113
+
114
+ /** Clear group/world bits on this inode. A symlink, including a "." suffix, is not opened. */
115
+ function tightenOwnerOnly(target: string, mode: number, kind: "directory" | "file"): void {
116
+ const lexical = lexicalLogPath(target);
117
+ if (skipRootModeRepair(lexical) || isFinalSymlink(lexical)) return;
118
+ // A trailing "/." is not the directory. lstat and O_NOFOLLOW must use the
119
+ // same path the symlink check used, or a swapped link is followed.
120
+ const repairPath = kind === "directory" ? pathForSymlinkCheck(lexical) : lexical;
121
+ let fd: number | undefined;
122
+ try {
123
+ const listed = fs.lstatSync(repairPath);
124
+ if (kind === "directory" ? !listed.isDirectory() : !listed.isFile()) return;
125
+ if (inodeIsFsRoot(listed) || (listed.mode & 0o077) === 0) return;
126
+ const noFollow = (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
127
+ const directoryFlag = kind === "directory" ? (fs.constants.O_DIRECTORY ?? 0) : 0;
128
+ const writeOnly = fs.constants.O_WRONLY | noFollow;
129
+ const readOnly = fs.constants.O_RDONLY | noFollow | directoryFlag;
130
+ try {
131
+ fd = fs.openSync(repairPath, kind === "file" ? writeOnly : readOnly);
132
+ } catch (error) {
133
+ const code = (error as NodeJS.ErrnoException).code;
134
+ if (code !== "EACCES" && code !== "EPERM") throw error;
135
+ try {
136
+ if (kind !== "file") throw error;
137
+ fd = fs.openSync(repairPath, fs.constants.O_RDONLY | noFollow);
138
+ } catch (again) {
139
+ const againCode = (again as NodeJS.ErrnoException).code;
140
+ if (againCode !== "EACCES" && againCode !== "EPERM") throw again;
141
+ reportSkippedRepair(kind);
142
+ return;
143
+ }
144
+ }
145
+ const opened = fs.fstatSync(fd);
146
+ if (opened.dev !== listed.dev || opened.ino !== listed.ino || inodeIsFsRoot(opened)) return;
147
+ if (kind === "directory" ? !opened.isDirectory() : !opened.isFile()) return;
148
+ if ((opened.mode & 0o077) === 0) return;
149
+ fs.fchmodSync(fd, mode);
150
+ } catch {
151
+ // A mode repair must not take logging down.
152
+ } finally {
153
+ if (fd !== undefined) {
154
+ try {
155
+ fs.closeSync(fd);
156
+ } catch {
157
+ // The mode was already repaired, or the close raced an exit.
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /** Ensure a logs directory exists at mode 0700; return the path the transport should use. */
18
164
  function ensureDir(dir: string): string {
19
- if (!fs.existsSync(dir)) {
20
- fs.mkdirSync(dir, { recursive: true });
165
+ const lexical = lexicalLogPath(dir);
166
+ if (skipRootModeRepair(lexical) || isFinalSymlink(lexical)) return lexical;
167
+ if (!fs.existsSync(lexical)) {
168
+ fs.mkdirSync(lexical, { recursive: true, mode: LOG_DIR_MODE });
21
169
  }
22
- return dir;
170
+ tightenOwnerOnly(lexical, LOG_DIR_MODE, "directory");
171
+ return lexical;
23
172
  }
24
173
 
25
174
  type WinstonModule = typeof import("winston");
@@ -34,6 +183,7 @@ let dailyRotateFileCtor: DailyRotateFileCtor | undefined;
34
183
  let winstonLogger: Logger | undefined;
35
184
  let loggerInit: Promise<Logger> | undefined;
36
185
  let flushingBufferedLogs = false;
186
+ let repairNoteQueued = false;
37
187
  const bufferedLogs: LogRecord[] = [];
38
188
  /** Cap pre-init buffering so a hung/failed winston import cannot grow heap unboundedly. */
39
189
  const MAX_BUFFERED_LOGS = 10_000;
@@ -73,18 +223,57 @@ function makeLogFormat(winston: WinstonModule): winston.Logform.Format {
73
223
  );
74
224
  }
75
225
 
76
- /** Build a rotating file transport, materializing the target directory lazily. */
226
+ /**
227
+ * Build a rotating file transport, materializing the target directory lazily.
228
+ *
229
+ * Destination precedence:
230
+ * 1. `dir` — an explicit path from {@link setTransports}(`{ file: "<path>" }`).
231
+ * 2. {@link getEffectiveLogsDir} — the provenance-checked `SKC_LOG_DIR`
232
+ * override, else the real config root (`~/.skc/logs`).
233
+ *
234
+ * The resolution is centralized in `dirs.ts` rather than read from the
235
+ * environment here: the log *readers* (report bundles, the debug log view, the
236
+ * HTTP dump directory) call the same helper, and a second env read in this file
237
+ * is what let the transport write somewhere the readers never looked.
238
+ */
77
239
  function makeFileTransport(DailyRotateFile: DailyRotateFileCtor, dir?: string): Transport {
78
- return new DailyRotateFile({
79
- dirname: ensureDir(dir ?? getLogsDir()),
240
+ const dirname = ensureDir(dir ?? getEffectiveLogsDir());
241
+ const transport = new DailyRotateFile({
242
+ dirname,
80
243
  filename: "skc.%DATE%.log",
81
244
  datePattern: "YYYY-MM-DD",
82
245
  maxSize: "10m",
83
246
  maxFiles: 5,
84
247
  zippedArchive: true,
248
+ // `options` replaces the library default `{ flags: "a" }`. Keep append so
249
+ // an existing log is not truncated. `mode` applies when the file is created.
250
+ options: { flags: "a", mode: LOG_FILE_MODE },
85
251
  });
252
+ // winston-daily-rotate-file never listens on its file-stream-rotator stream,
253
+ // so a failed open or write of the log file (EACCES, EISDIR, EMFILE, the log
254
+ // directory removed under a live process) is emitted with no listener and
255
+ // becomes an uncaught exception from a single `logger.warn()`.
256
+ const stream = transport.logStream as unknown as NodeJS.EventEmitter;
257
+ let currentLog: string | undefined;
258
+ const repairLog = (): void => {
259
+ if (!currentLog) return;
260
+ tightenOwnerOnly(currentLog, LOG_FILE_MODE, "file");
261
+ };
262
+ // `new` names the active file (including one that already existed at 0666).
263
+ // `open` runs after a newly created file exists, so a late create is covered.
264
+ stream.on("new", (file: unknown) => {
265
+ if (typeof file !== "string") return;
266
+ currentLog = file;
267
+ repairLog();
268
+ });
269
+ stream.on("open", repairLog);
270
+ stream.on("error", ignoreSinkError);
271
+ return transport;
86
272
  }
87
273
 
274
+ /** Logging failures must never take the process down; the record is dropped. */
275
+ function ignoreSinkError(): void {}
276
+
88
277
  function makeConsoleTransport(winston: WinstonModule): Transport {
89
278
  return new winston.transports.Console({ format: makeLogFormat(winston) });
90
279
  }
@@ -94,6 +283,7 @@ function applyTransports(
94
283
  modules: { winston: WinstonModule; DailyRotateFile: DailyRotateFileCtor },
95
284
  ): void {
96
285
  logger.clear();
286
+ logger.silent = !transportOptions.console && !transportOptions.file;
97
287
  if (transportOptions.file) {
98
288
  logger.add(
99
289
  makeFileTransport(
@@ -117,6 +307,9 @@ async function getWinstonLogger(): Promise<Logger> {
117
307
  // Don't exit on error - logging failures shouldn't crash the app
118
308
  exitOnError: false,
119
309
  });
310
+ // winston re-emits every transport `error` on the logger itself; with no
311
+ // listener that re-emit throws.
312
+ logger.on("error", ignoreSinkError);
120
313
  applyTransports(logger, modules);
121
314
  winstonLogger = logger;
122
315
  flushBufferedLogs();
@@ -142,6 +335,35 @@ function flushBufferedLogs(): void {
142
335
  }
143
336
  }
144
337
 
338
+ /**
339
+ * A skipped mode repair is logged through the transports already configured.
340
+ * The default is file-only, so this must not write the terminal. Console
341
+ * output stays opt-in via {@link setTransports}(`{ console: true }`).
342
+ */
343
+ function reportSkippedRepair(kind: "directory" | "file"): void {
344
+ if (flushingBufferedLogs) return;
345
+ if (!transportOptions.console && !transportOptions.file) return;
346
+ bufferedLogs.push({
347
+ level: "error",
348
+ message: `rotating log mode repair skipped for an unopenable ${kind}`,
349
+ });
350
+ if (bufferedLogs.length > MAX_BUFFERED_LOGS) {
351
+ bufferedLogs.splice(0, bufferedLogs.length - MAX_BUFFERED_LOGS);
352
+ }
353
+ // Init flushes after the transports exist. A later repair runs from a
354
+ // stream callback, so deliver it after that callback returns.
355
+ if (!winstonLogger || repairNoteQueued) return;
356
+ repairNoteQueued = true;
357
+ queueMicrotask(() => {
358
+ repairNoteQueued = false;
359
+ try {
360
+ flushBufferedLogs();
361
+ } catch {
362
+ // A repair notice must not take logging down.
363
+ }
364
+ });
365
+ }
366
+
145
367
  function writeLog(level: LogLevel, message: string, context?: Record<string, unknown>): void {
146
368
  try {
147
369
  if (winstonLogger && !flushingBufferedLogs) {
@@ -249,21 +471,21 @@ export function printTimings(): void {
249
471
  }
250
472
 
251
473
  gRootSpan.end = performance.now();
474
+ // Close still-open spans (e.g. cli:dispatch, which wraps the very run that
475
+ // prints) so they report elapsed-at-print instead of a misleading 0ms.
476
+ const root = gRootSpan;
477
+ const printNow = root.end;
478
+ const closeOpenSpans = (span: Span): void => {
479
+ for (const child of span.children) closeOpenSpans(child);
480
+ if (span.end === undefined && !span.point) span.end = printNow;
481
+ };
482
+ closeOpenSpans(root);
252
483
  const lines: string[] = [];
253
484
  lines.push("");
254
485
  lines.push("--- Startup timings (hierarchical) ---");
255
- const work: Span[] = [];
256
- const loads: Span[] = [];
257
- for (const child of gRootSpan.children) {
258
- if (isModuleLoadSpan(child)) loads.push(child);
259
- else work.push(child);
260
- }
261
- for (const child of work.sort((a, b) => a.start - b.start)) {
486
+ for (const child of [...gRootSpan.children].sort((a, b) => a.start - b.start)) {
262
487
  printSpan(child, 0, lines);
263
488
  }
264
- if (loads.length > 0) {
265
- printModuleLoadSummary(loads, 0, lines);
266
- }
267
489
  const totalMs = (gRootSpan.end - gRootSpan.start).toFixed(1);
268
490
  lines.push(`Total: ${totalMs}ms`);
269
491
  lines.push("--------------------------------------");
@@ -274,46 +496,28 @@ export function printTimings(): void {
274
496
 
275
497
  /**
276
498
  * Begin recording startup timings under a new root span.
277
- * Idempotent: a second call while already recording is a no-op so that side-effect
278
- * starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
499
+ * Idempotent: a second call while already recording is a no-op so that the
500
+ * early starter (cli.ts, the first CLI statement) and the explicit starter
501
+ * (main.ts) can coexist.
502
+ *
503
+ * The root is anchored at the process-start origin rather than at the call:
504
+ * `performance.now()` counts milliseconds since `performance.timeOrigin`
505
+ * (process start) on Bun, so `start: 0` makes `Total` the true
506
+ * time-since-process-start. Runtime bootstrap plus static module linking and
507
+ * evaluation then land in the root's self time instead of being invisible
508
+ * before the first statement of cli.ts.
279
509
  */
280
510
  export function startTiming(): void {
281
511
  if (gRecordTimings) return;
282
512
  gRootSpan = {
283
513
  op: "(root)",
284
- start: performance.now(),
514
+ start: 0,
285
515
  parent: undefined,
286
516
  children: [],
287
517
  };
288
518
  gRecordTimings = true;
289
519
  }
290
520
 
291
- /**
292
- * Record an externally-measured span as a leaf child of the active span (or root
293
- * when no span is active). Used by the module-load timing plugin to splice load
294
- * events into the tree retroactively.
295
- */
296
- export function recordModuleLoadSpan(path: string, start: number, durationMs: number): void {
297
- if (!gRecordTimings || !gRootSpan) return;
298
- const parent = spanStorage.getStore() ?? gRootSpan;
299
- const span: Span = {
300
- op: `load:${shortenLoadPath(path)}`,
301
- start,
302
- end: start + durationMs,
303
- parent,
304
- children: [],
305
- };
306
- parent.children.push(span);
307
- }
308
-
309
- function shortenLoadPath(p: string): string {
310
- const cwd = process.cwd();
311
- if (p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1);
312
- const home = process.env.HOME;
313
- if (home && p.startsWith(`${home}/`)) return `~/${p.slice(home.length + 1)}`;
314
- return p;
315
- }
316
-
317
521
  /**
318
522
  * End timing window and clear buffers.
319
523
  */
@@ -359,13 +563,6 @@ function fmtMs(ms: number): string {
359
563
  return `${ms.toFixed(0)}ms`;
360
564
  }
361
565
 
362
- const MODULE_LOAD_PREFIX = "load:";
363
- const MODULE_LOAD_VERBOSE_TOP = 10;
364
-
365
- function isModuleLoadSpan(span: Span): boolean {
366
- return span.op.startsWith(MODULE_LOAD_PREFIX);
367
- }
368
-
369
566
  function printSpan(span: Span, depth: number, lines: string[]): void {
370
567
  const indent = " ".repeat(depth);
371
568
  if (span.point) {
@@ -380,51 +577,9 @@ function printSpan(span: Span, depth: number, lines: string[]): void {
380
577
  const selfStr = span.children.length > 0 && self > LOGGED_TIMING_THRESHOLD_MS ? ` (self ${fmtMs(self)})` : "";
381
578
  lines.push(`${indent}${span.op}: ${fmtMs(dur)}${selfStr}${tag}`);
382
579
 
383
- // Split children into work spans and module-load spans for summarization.
384
- const work: Span[] = [];
385
- const loads: Span[] = [];
386
- for (const child of span.children) {
387
- if (isModuleLoadSpan(child)) loads.push(child);
388
- else work.push(child);
389
- }
390
- for (const child of work.sort((a, b) => a.start - b.start)) {
580
+ for (const child of [...span.children].sort((a, b) => a.start - b.start)) {
391
581
  printSpan(child, depth + 1, lines);
392
582
  }
393
- if (loads.length > 0) {
394
- printModuleLoadSummary(loads, depth + 1, lines);
395
- }
396
- }
397
-
398
- /** Collapse the (typically hundreds of) module-load spans into one summary line. */
399
- function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]): void {
400
- const childIndent = " ".repeat(depth);
401
- const grandIndent = " ".repeat(depth + 1);
402
- let unionStart = Number.POSITIVE_INFINITY;
403
- let unionEnd = 0;
404
- let totalSelf = 0;
405
- for (const span of loads) {
406
- if (span.end === undefined) continue;
407
- if (span.start < unionStart) unionStart = span.start;
408
- if (span.end > unionEnd) unionEnd = span.end;
409
- totalSelf += span.end - span.start;
410
- }
411
- const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
412
- lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
413
- // Resolve SKC-first with PI fallback inline (no ./env import) so this
414
- // foundational logger stays off the env module's dependency graph, which the
415
- // tab-worker native-free runtime contract (issue-2598-repro) walks.
416
- const showAll = (process.env.SKC_TIMING?.trim() || process.env.PI_TIMING?.trim()) === "full";
417
- const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
418
- const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
419
- for (const span of visible) {
420
- const dur = durationOf(span);
421
- if (dur < LOGGED_TIMING_THRESHOLD_MS) break;
422
- const tag = isParallel(span) ? " [parallel]" : "";
423
- lines.push(`${grandIndent}${span.op}: ${fmtMs(dur)}${tag}`);
424
- }
425
- if (!showAll && sorted.length > MODULE_LOAD_VERBOSE_TOP) {
426
- lines.push(`${grandIndent}… ${sorted.length - MODULE_LOAD_VERBOSE_TOP} more (PI_TIMING=full to show all)`);
427
- }
428
583
  }
429
584
 
430
585
  /** A span is parallel if it overlaps a sibling that started before it. */
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Lazy synchronous binding for @sayknow-cli/natives process control.
3
+ *
4
+ * The natives package entry loads the compiled addon at import time, so a
5
+ * static import anywhere in the `@sayknow-cli/utils` root barrel graph would
6
+ * materialize the addon for every barrel consumer. The W5b S1/idle
7
+ * module-trace gate requires that merely importing the barrel never loads
8
+ * @sayknow-cli/natives; process-control callers bind at first real use instead.
9
+ */
10
+ type NativeProcessBindings = Pick<typeof import("@sayknow-cli/natives"), "Process" | "ProcessStatus">;
11
+
12
+ let bindings: NativeProcessBindings | undefined;
13
+
14
+ export function nativeProcessBindings(): NativeProcessBindings {
15
+ if (!bindings) {
16
+ bindings = require("@sayknow-cli/natives") as NativeProcessBindings;
17
+ }
18
+ return bindings;
19
+ }
package/src/postmortem.ts CHANGED
@@ -10,6 +10,7 @@ import inspector from "node:inspector";
10
10
  import * as path from "node:path";
11
11
  import { isMainThread } from "node:worker_threads";
12
12
  import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
13
+ import { redactCrashSecrets } from "./crash-redaction";
13
14
  import { getCrashLogPath } from "./dirs";
14
15
  import * as logger from "./logger";
15
16
  import { safeStderrWrite } from "./safe-stderr";
@@ -217,61 +218,6 @@ export const CRASH_LOG_MAX_BYTES = 512 * 1024;
217
218
  export const CRASH_RECORD_MAX_BYTES = 64 * 1024;
218
219
  const CRASH_RECORD_TRUNCATION_MARKER = "\n… [crash record truncated]\n\n";
219
220
 
220
- /**
221
- * Best-effort scrub of credential material from a crash record before it is
222
- * persisted indefinitely. Covers bearer/basic-style headers, key=value or
223
- * JSON key forms of common credential names, and well-known vendor token
224
- * shapes. Normal messages and stack frames are untouched; matches are
225
- * replaced in place so surrounding diagnostic context survives.
226
- */
227
- function redactCrashSecrets(text: string): string {
228
- let redacted = text;
229
- redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
230
- redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
231
- redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
232
- // `gh[opsur]_` covers the classic PAT/OAuth/server/user/refresh prefixes;
233
- // fine-grained PATs use an entirely different `github_pat_` prefix.
234
- redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
235
- redacted = redacted.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "«redacted-github-token»");
236
- redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
237
- redacted = redacted.replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "«redacted-npm-token»");
238
- redacted = redacted.replace(/\bglpat-[A-Za-z0-9_-]{20,}\b/g, "«redacted-gitlab-token»");
239
- // Stripe separates with `_`, so the `sk-` rule above never matched one.
240
- redacted = redacted.replace(/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, "«redacted-api-key»");
241
- redacted = redacted.replace(/\bhf_[A-Za-z0-9]{20,}\b/g, "«redacted-api-key»");
242
- // A PEM block carries the key material itself, so it is redacted whole rather
243
- // than line by line, before narrower rules can chew on the base64 body.
244
- redacted = redacted.replace(
245
- /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
246
- "«redacted-private-key»",
247
- );
248
- // Google API keys are a fixed 39-character shape with no label of their own.
249
- redacted = redacted.replace(
250
- /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9_-])/g,
251
- "«redacted-google-api-key»",
252
- );
253
- // Basic-auth credentials embedded in a URL. Scheme and host stay readable
254
- // because they are the diagnostic value; only the userinfo is dropped. The
255
- // scheme-character run is boundary anchored so a long alphabetic run cannot
256
- // go quadratic, while leading digits and scheme punctuation stay in the
257
- // capture so longer and boundary-adjacent URLs still redact.
258
- redacted = redacted.replace(
259
- /(?<![A-Za-z0-9+.-])([0-9+.-]*[a-z][a-z0-9+.-]*:\/\/)[^/\s:@]{1,256}:[^/\s@]{1,256}@/gi,
260
- "$1«redacted-url-credential»@",
261
- );
262
- // AKIA is the long-term AWS access key id; ASIA the temporary/STS one, which
263
- // is the shape that actually shows up in a crashed request. ABIA/ACCA
264
- // complete the set. The id alone is not the credential: STS payloads carry
265
- // `SecretAccessKey` and `SessionToken` alongside it, so the labeled-value
266
- // rule below names both.
267
- redacted = redacted.replace(/\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
268
- redacted = redacted.replace(
269
- /(?<![A-Za-z0-9_])(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|secret[_-]?key|secret[_-]?access[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
270
- "$1«redacted»",
271
- );
272
- return redacted;
273
- }
274
-
275
221
  /**
276
222
  * Bound one record to CRASH_RECORD_MAX_BYTES without splitting a UTF-8
277
223
  * sequence. Keeps the header (timestamp/label/message) at the front, where