@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.
- 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/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 {
|
|
16
|
+
import { getEffectiveLogsDir } from "./dirs";
|
|
16
17
|
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
278
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|