@sayknow-cli/utils 0.7.11 → 0.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/types/broken-pipe.d.ts +4 -3
  2. package/dist/types/canonical-log-dir.d.ts +20 -0
  3. package/dist/types/cli-metadata.d.ts +33 -0
  4. package/dist/types/cli.d.ts +12 -0
  5. package/dist/types/crash-fingerprint.d.ts +82 -0
  6. package/dist/types/crash-journal.d.ts +84 -0
  7. package/dist/types/crash-redaction.d.ts +20 -0
  8. package/dist/types/dirs.d.ts +127 -36
  9. package/dist/types/env-file.d.ts +59 -0
  10. package/dist/types/env.d.ts +19 -1
  11. package/dist/types/error-classification.d.ts +12 -0
  12. package/dist/types/fetch-retry.d.ts +6 -3
  13. package/dist/types/header-value.d.ts +10 -0
  14. package/dist/types/index.d.ts +6 -0
  15. package/dist/types/logger.d.ts +22 -8
  16. package/dist/types/native-process.d.ts +12 -0
  17. package/dist/types/procmgr.d.ts +1 -35
  18. package/dist/types/safe-error.d.ts +20 -0
  19. package/dist/types/sanitize-text.d.ts +10 -0
  20. package/dist/types/shell-config.d.ts +58 -0
  21. package/dist/types/stream.d.ts +2 -1
  22. package/dist/types/which.d.ts +1 -1
  23. package/package.json +2 -2
  24. package/src/broken-pipe.ts +16 -4
  25. package/src/canonical-log-dir.ts +59 -0
  26. package/src/cli-metadata.ts +74 -0
  27. package/src/cli.ts +30 -2
  28. package/src/crash-fingerprint.ts +294 -0
  29. package/src/crash-journal.ts +292 -0
  30. package/src/crash-redaction.ts +558 -0
  31. package/src/dirs.ts +583 -165
  32. package/src/env-file.ts +326 -20
  33. package/src/env.ts +321 -35
  34. package/src/error-classification.ts +31 -0
  35. package/src/fetch-retry.ts +23 -10
  36. package/src/frontmatter.ts +28 -2
  37. package/src/glob.ts +13 -10
  38. package/src/header-value.ts +15 -0
  39. package/src/index.ts +6 -0
  40. package/src/logger.ts +252 -97
  41. package/src/native-process.ts +19 -0
  42. package/src/postmortem.ts +1 -55
  43. package/src/procmgr.ts +16 -212
  44. package/src/ptree.ts +3 -2
  45. package/src/safe-error.ts +40 -0
  46. package/src/sanitize-text.ts +14 -0
  47. package/src/shell-config.ts +329 -0
  48. package/src/stream.ts +32 -1
  49. package/src/which.ts +6 -5
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Crash fingerprinting, algorithm v1.
3
+ *
4
+ * A fingerprint is a stable, versioned identity for a crash *class*, computed
5
+ * at `recordFatalCrash` time from the already-captured diagnostic text (error
6
+ * name, message, stack) and nothing else. It exists so that N crash-loop
7
+ * records collapse into one countable signature.
8
+ *
9
+ * ## Privacy posture
10
+ *
11
+ * The fingerprint is a **public, pseudonymous correlation token**. It is
12
+ * deterministic over low-entropy inputs, therefore dictionary-testable, and it
13
+ * links the same crash class across installs and accounts. It is NOT a
14
+ * confidentiality control. Only *normalized* text is hashed — absolute paths,
15
+ * home directories, uuids/hex/long digit runs and everything
16
+ * `redactCrashSecrets` rewrites are replaced with placeholders before hashing —
17
+ * so a fingerprint never encodes a secret or a raw filesystem path.
18
+ *
19
+ * ## Canonical serialization (v1)
20
+ *
21
+ * Fields are length-prefixed UTF-8 (`<byteLength>:<bytes>`) so no delimiter
22
+ * ambiguity exists between an empty field and a missing one:
23
+ *
24
+ * "skc-crash-fp.v1" | errorName | messageClass | frame0 | frame1 | frame2
25
+ *
26
+ * Up to three normalized in-app frames participate; absent frames are omitted
27
+ * (the length prefix of the preceding fields keeps the encoding unambiguous).
28
+ * The digest is sha256 truncated to its first 16 bytes, published as 32
29
+ * lowercase hex characters (128 bits).
30
+ */
31
+ import { createHash } from "node:crypto";
32
+ import * as os from "node:os";
33
+ import * as path from "node:path";
34
+ import { redactCrashSecrets } from "./crash-redaction";
35
+
36
+ /** Algorithm version recorded beside every emitted fingerprint. */
37
+ export const CRASH_FINGERPRINT_VERSION = 1;
38
+ /** Hex length of a published fingerprint (128 bits). */
39
+ export const CRASH_FINGERPRINT_HEX_LENGTH = 32;
40
+ /** Matches exactly a published fingerprint. */
41
+ export const CRASH_FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
42
+ /** Marker used for the machine-readable identity line of each crash record. */
43
+ export const CRASH_RECORD_MARKER = "skc-crash-record.v1";
44
+ /** Marker embedded in an external issue body, outside crash-derived blocks. */
45
+ export const CRASH_ISSUE_MARKER_PREFIX = "skc-crash-fp.v1:";
46
+ /** Literal frame used when a stack carries no in-app frame at all. */
47
+ export const NO_APP_FRAME = "<no-app-frame>";
48
+
49
+ /** Byte caps applied before hashing, so a huge throwable cannot dominate cost. */
50
+ const MESSAGE_CLASS_MAX_BYTES = 512;
51
+ const FRAME_MAX_BYTES = 256;
52
+ const MAX_FRAMES = 3;
53
+
54
+ export interface CrashFingerprintInput {
55
+ readonly name: string;
56
+ readonly message: string;
57
+ readonly stack: string;
58
+ }
59
+
60
+ export interface CrashFingerprint {
61
+ /** 32 lowercase hex characters. */
62
+ readonly fingerprint: string;
63
+ /** Algorithm version (`fpv`). */
64
+ readonly version: number;
65
+ readonly errorName: string;
66
+ /** Normalized, placeholder-substituted message class (safe to display). */
67
+ readonly messageClass: string;
68
+ /** Normalized in-app frames that participated in the digest. */
69
+ readonly frames: readonly string[];
70
+ }
71
+
72
+ export interface CrashFingerprintOptions {
73
+ /** Install root used to relativize in-app frames. Defaults to the SKC install root. */
74
+ readonly installRoot?: string;
75
+ /** Home directory used for `<home>` substitution. Defaults to `os.homedir()`. */
76
+ readonly homeDir?: string;
77
+ }
78
+
79
+ /**
80
+ * Root of this installation, used to relativize frames.
81
+ *
82
+ * Source checkouts resolve to the workspace root (this file lives at
83
+ * `packages/utils/src/`); an npm install resolves to the `node_modules` root.
84
+ * Both are stable per install *shape*, which is what frame identity needs;
85
+ * compiled binaries never reach here because their frames are BunFS paths.
86
+ */
87
+ function defaultInstallRoot(): string {
88
+ return path.resolve(import.meta.dir, "..", "..", "..");
89
+ }
90
+
91
+ function truncateUtf8(text: string, maxBytes: number): string {
92
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
93
+ const bytes = Buffer.from(text, "utf8");
94
+ let end = maxBytes;
95
+ while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
96
+ if (end > 0 && bytes[end - 1] >= 0xc0) end--;
97
+ return bytes.subarray(0, end).toString("utf8");
98
+ }
99
+
100
+ const UUID_PATTERN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g;
101
+ const HEX_RUN_PATTERN = /\b[0-9a-fA-F]{8,}\b/g;
102
+ const DIGIT_RUN_PATTERN = /\d{4,}/g;
103
+ // UNC, Windows drive-letter, BunFS (posix + windows compiled forms), POSIX.
104
+ const PATH_PATTERN =
105
+ /\\\\[^\s"'`,;)\]]+|\b[A-Za-z]:\\[^\s"'`,;)\]]*|\/\$bunfs\/[^\s"'`,;)\]]*|(?<![\w.~])\/[^\s"'`,;)\]]*/g;
106
+
107
+ function normalizeSeparators(value: string): string {
108
+ return value.replace(/\\/g, "/");
109
+ }
110
+
111
+ /**
112
+ * Replace absolute path-like tokens with `<home>` / `<path>`.
113
+ *
114
+ * Semantically meaningful codes are untouched: this rule only fires on tokens
115
+ * that are recognizably absolute filesystem paths.
116
+ */
117
+ export function replaceAbsolutePaths(text: string, homeDir: string = os.homedir()): string {
118
+ const normalizedHome = normalizeSeparators(homeDir).replace(/\/+$/, "");
119
+ return text.replace(PATH_PATTERN, match => {
120
+ if (match === "/") return match;
121
+ const normalized = normalizeSeparators(match);
122
+ if (normalizedHome.length > 0 && (normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`)))
123
+ return "<home>";
124
+ return "<path>";
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Typed message normalization.
130
+ *
131
+ * Deliberately *not* "strip all digits": HTTP statuses, exit codes and errno
132
+ * names are the difference between distinct crash classes, so runs of three or
133
+ * fewer digits and alphabetic error codes survive verbatim (`404` stays
134
+ * distinct from `500`). Only high-entropy identifiers are collapsed.
135
+ */
136
+ export function normalizeCrashMessage(message: string, options: CrashFingerprintOptions = {}): string {
137
+ const homeDir = options.homeDir ?? os.homedir();
138
+ let normalized = redactCrashSecrets(message);
139
+ normalized = replaceAbsolutePaths(normalized, homeDir);
140
+ normalized = normalized.replace(UUID_PATTERN, "<uuid>");
141
+ normalized = normalized.replace(HEX_RUN_PATTERN, match => (/[a-fA-F]/.test(match) ? "<hex>" : "<num>"));
142
+ normalized = normalized.replace(DIGIT_RUN_PATTERN, "<num>");
143
+ normalized = normalized.replace(/\s+/g, " ").trim();
144
+ return truncateUtf8(normalized, MESSAGE_CLASS_MAX_BYTES);
145
+ }
146
+
147
+ interface ParsedFrame {
148
+ readonly functionName: string;
149
+ readonly location: string;
150
+ }
151
+
152
+ function parseStackFrame(line: string): ParsedFrame | undefined {
153
+ const trimmed = line.trim();
154
+ const atMatch = /^at\s+(.*)$/.exec(trimmed);
155
+ if (!atMatch) return undefined;
156
+ const rest = atMatch[1] ?? "";
157
+ const parenIndex = rest.lastIndexOf(" (");
158
+ let functionName = "<anonymous>";
159
+ let location = rest;
160
+ if (parenIndex >= 0 && rest.endsWith(")")) {
161
+ functionName = rest.slice(0, parenIndex).trim();
162
+ location = rest.slice(parenIndex + 2, -1).trim();
163
+ }
164
+ if (!location) return undefined;
165
+ return { functionName, location };
166
+ }
167
+
168
+ function stripLocation(location: string): string {
169
+ let value = location;
170
+ if (value.startsWith("file://")) value = Bun.fileURLToPath(value);
171
+ // Drop `:line:col` / `:line` suffixes: they churn on every release.
172
+ value = value.replace(/:\d+(?::\d+)?$/, "");
173
+ return value;
174
+ }
175
+
176
+ function normalizeFunctionName(name: string): string {
177
+ let value = name.replace(/^(?:async|new)\s+/, "").trim();
178
+ value = value.replace(/\s+\[as\s+[^\]]+\]$/, "");
179
+ if (!value || value === "<anonymous>") return "<anonymous>";
180
+ return value;
181
+ }
182
+
183
+ function relativizeInApp(location: string, installRoot: string): string | undefined {
184
+ const normalized = normalizeSeparators(location);
185
+ if (!normalized || normalized === "native" || normalized === "<anonymous>") return undefined;
186
+ if (/^(?:node|bun):/.test(normalized)) return undefined;
187
+ if (/(?:^|\/)node_modules\//.test(normalized)) return undefined;
188
+ // Compiled-binary frames: posix `/$bunfs/root/...` and windows `B:\~BUN\root\...`.
189
+ const bunfs = /^(?:[A-Za-z]:\/~BUN|\/\$bunfs)\/root\/(.*)$/.exec(normalized);
190
+ if (bunfs) return bunfs[1] ?? undefined;
191
+ const root = normalizeSeparators(installRoot).replace(/\/+$/, "");
192
+ if (root.length > 0 && normalized.startsWith(`${root}/`)) return normalized.slice(root.length + 1);
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Normalized in-app frames, newest first, capped at three.
198
+ *
199
+ * A frame is `<install-root-relative path>#<function>` with no line or column
200
+ * numbers. Stacks with no in-app frame yield the single literal
201
+ * `<no-app-frame>`; distinct roots can merge under that literal, which is an
202
+ * accepted and documented v1 property.
203
+ */
204
+ export function normalizeCrashFrames(stack: string, options: CrashFingerprintOptions = {}): string[] {
205
+ const installRoot = options.installRoot ?? defaultInstallRoot();
206
+ const frames: string[] = [];
207
+ for (const line of stack.split("\n")) {
208
+ if (frames.length >= MAX_FRAMES) break;
209
+ const parsed = parseStackFrame(line);
210
+ if (!parsed) continue;
211
+ const relative = relativizeInApp(stripLocation(parsed.location), installRoot);
212
+ if (relative === undefined) continue;
213
+ frames.push(truncateUtf8(`${relative}#${normalizeFunctionName(parsed.functionName)}`, FRAME_MAX_BYTES));
214
+ }
215
+ return frames.length > 0 ? frames : [NO_APP_FRAME];
216
+ }
217
+
218
+ function canonicalSerialization(fields: readonly string[]): Buffer {
219
+ const parts: Buffer[] = [];
220
+ for (const field of fields) {
221
+ const bytes = Buffer.from(field, "utf8");
222
+ parts.push(Buffer.from(`${bytes.byteLength}:`, "utf8"), bytes);
223
+ }
224
+ return Buffer.concat(parts);
225
+ }
226
+
227
+ function computeFingerprint(
228
+ input: CrashFingerprintInput,
229
+ options: CrashFingerprintOptions,
230
+ includeMessage: boolean,
231
+ ): CrashFingerprint {
232
+ const errorName = truncateUtf8(normalizeCrashMessage(input.name, options) || "Error", 128);
233
+ const messageClass = normalizeCrashMessage(input.message, options);
234
+ const frames = normalizeCrashFrames(input.stack, options);
235
+ const identity = includeMessage
236
+ ? ["skc-crash-fp.v1", errorName, messageClass, ...frames]
237
+ : ["skc-crash-fp.v1", "handled", errorName, frames[0] ?? NO_APP_FRAME];
238
+ const digest = createHash("sha256").update(canonicalSerialization(identity)).digest();
239
+ return {
240
+ fingerprint: digest.subarray(0, CRASH_FINGERPRINT_HEX_LENGTH / 2).toString("hex"),
241
+ version: CRASH_FINGERPRINT_VERSION,
242
+ errorName,
243
+ messageClass,
244
+ frames,
245
+ };
246
+ }
247
+
248
+ /** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
249
+ export function computeCrashFingerprint(
250
+ input: CrashFingerprintInput,
251
+ options: CrashFingerprintOptions = {},
252
+ ): CrashFingerprint {
253
+ return computeFingerprint(input, options, true);
254
+ }
255
+
256
+ /**
257
+ * Compute the stable identity used for a handled tool error.
258
+ *
259
+ * Tool failures often carry command output or other per-occurrence detail in
260
+ * their message. That text is useful in the record body but is not the failure
261
+ * identity: handled errors group by error class and the first in-app frame
262
+ * where the failure originated, rather than the full wrapper stack.
263
+ */
264
+ export function computeHandledErrorFingerprint(
265
+ input: CrashFingerprintInput,
266
+ options: CrashFingerprintOptions = {},
267
+ ): CrashFingerprint {
268
+ return computeFingerprint(input, options, false);
269
+ }
270
+
271
+ /** The machine-readable identity line appended to every new crash record. */
272
+ export function formatCrashRecordMarker(fingerprint: string, version: number, recordId: string): string {
273
+ return `${CRASH_RECORD_MARKER} fp:${fingerprint} fpv:${version} id:${recordId}`;
274
+ }
275
+
276
+ export interface CrashRecordMarker {
277
+ readonly fingerprint: string;
278
+ readonly version: number;
279
+ readonly recordId: string;
280
+ }
281
+
282
+ /**
283
+ * Parse an identity line. Records written before this feature carry no marker
284
+ * and are therefore `unmatchable`: this parser never guesses at them.
285
+ */
286
+ export function parseCrashRecordMarker(line: string): CrashRecordMarker | undefined {
287
+ const match = new RegExp(`^${CRASH_RECORD_MARKER} fp:([0-9a-f]{32}) fpv:(\\d{1,3}) id:([0-9a-f]{8,32})$`).exec(
288
+ line.trim(),
289
+ );
290
+ if (!match) return undefined;
291
+ const version = Number(match[2]);
292
+ if (!Number.isSafeInteger(version) || version < 1) return undefined;
293
+ return { fingerprint: match[1] as string, version, recordId: match[3] as string };
294
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Append-only crash event journal.
3
+ *
4
+ * The fatal path is the most hostile place in the process: it runs while the
5
+ * program is already broken, possibly out of disk, possibly re-entered by a
6
+ * crash inside the crash handler. So the fatal path does exactly one thing
7
+ * here — append a single bounded line with `O_APPEND` — and never parses,
8
+ * locks, renames or reads. Aggregation into `skc-crash-index.json` happens at
9
+ * the next startup, under a cross-process lock, far away from the fatal path.
10
+ *
11
+ * The journal, not the index, is the source of increments: a lost index can be
12
+ * rebuilt from journal events, and concurrent compactors cannot drop counts.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { CRASH_FINGERPRINT_PATTERN } from "./crash-fingerprint";
17
+
18
+ /** Hard cap for one journal line, including its newline. */
19
+ export const CRASH_EVENT_MAX_BYTES = 512;
20
+ /** Journal line format tag. */
21
+ export const CRASH_EVENT_KIND = "skc-crash-event.v1";
22
+ /** Preview cap for the message class carried by an event. */
23
+ export const CRASH_EVENT_MESSAGE_MAX_BYTES = 256;
24
+
25
+ /** Bounded execution provenance carried only on newly written occurrences. */
26
+ export type CrashProvenance = "product" | "eval" | "bun_test";
27
+
28
+ /**
29
+ * Classify only process-level harness modes that are explicit and stable.
30
+ * Everything else is product provenance, including source checkouts, CLI
31
+ * invocations, SDK/ACP hosts, and native-loader failures.
32
+ */
33
+ export function detectCrashProvenance(
34
+ argv: readonly string[] = process.argv,
35
+ env: NodeJS.ProcessEnv = process.env,
36
+ execArgv: readonly string[] = process.execArgv,
37
+ ): CrashProvenance {
38
+ if (env.BUN_TEST !== undefined) return "bun_test";
39
+ if (execArgv[0] === "-e" || execArgv[0] === "--eval" || argv[0] === "-e" || argv[0] === "--eval") return "eval";
40
+ return "product";
41
+ }
42
+
43
+ export type CrashEvent =
44
+ | CrashOccurrenceEvent
45
+ | CrashRefusedEvent
46
+ | CrashReportedEvent
47
+ | CrashRelayedEvent
48
+ | CrashAcknowledgedEvent
49
+ | CrashNudgedEvent;
50
+
51
+ export interface CrashOccurrenceEvent {
52
+ readonly kind: "occurrence";
53
+ readonly fingerprint: string;
54
+ readonly fpv: number;
55
+ readonly recordId: string;
56
+ readonly at: number;
57
+ readonly errorName: string;
58
+ readonly messageClass: string;
59
+ /** Legacy events omit this and are treated as product crashes. */
60
+ readonly provenance?: CrashProvenance;
61
+ }
62
+
63
+ export interface CrashRefusedEvent {
64
+ readonly kind: "refused";
65
+ readonly fingerprint: string;
66
+ readonly fpv: number;
67
+ readonly recordId: string;
68
+ readonly contractVersion: string;
69
+ readonly at: number;
70
+ }
71
+
72
+ export interface CrashReportedEvent {
73
+ readonly kind: "reported";
74
+ readonly fingerprint: string;
75
+ readonly at: number;
76
+ readonly issueUrl: string;
77
+ /** Set when the submission was a "+1" comment rather than a new issue. */
78
+ readonly commented?: boolean;
79
+ }
80
+
81
+ export interface CrashRelayedEvent {
82
+ readonly kind: "relayed";
83
+ readonly fingerprint: string;
84
+ readonly at: number;
85
+ /** Sentry event id accepted upstream: 32 lowercase hex. */
86
+ readonly eventId: string;
87
+ /** Exact occurrence record represented by the accepted envelope. Absent on legacy pre-record-id relay lines. */
88
+ readonly recordId?: string;
89
+ }
90
+
91
+ export interface CrashAcknowledgedEvent {
92
+ readonly kind: "acknowledged";
93
+ readonly fingerprint: string;
94
+ readonly at: number;
95
+ }
96
+
97
+ export interface CrashNudgedEvent {
98
+ readonly kind: "nudged";
99
+ readonly at: number;
100
+ }
101
+
102
+ function truncateUtf8(text: string, maxBytes: number): string {
103
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
104
+ const bytes = Buffer.from(text, "utf8");
105
+ let end = maxBytes;
106
+ while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
107
+ if (end > 0 && bytes[end - 1] >= 0xc0) end--;
108
+ return bytes.subarray(0, end).toString("utf8");
109
+ }
110
+
111
+ /** Strip control characters so one event line can never contain a newline. */
112
+ function sanitizeEventText(text: string): string {
113
+ return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
114
+ }
115
+
116
+ /**
117
+ * Serialize one event to a single journal line (newline included), bounded to
118
+ * `CRASH_EVENT_MAX_BYTES`. Oversized message previews are shortened, and if the
119
+ * line still does not fit the preview is dropped entirely rather than the event.
120
+ */
121
+ export function formatCrashEventLine(event: CrashEvent): string {
122
+ const build = (messageClass?: string): string => {
123
+ const body: Record<string, unknown> =
124
+ event.kind === "occurrence"
125
+ ? {
126
+ k: "occurrence",
127
+ fp: event.fingerprint,
128
+ fpv: event.fpv,
129
+ id: event.recordId,
130
+ at: event.at,
131
+ n: sanitizeEventText(truncateUtf8(event.errorName, 64)),
132
+ ...(event.provenance && event.provenance !== "product" ? { p: event.provenance } : {}),
133
+ ...(messageClass === undefined ? {} : { m: messageClass }),
134
+ }
135
+ : event.kind === "reported"
136
+ ? {
137
+ k: "reported",
138
+ fp: event.fingerprint,
139
+ at: event.at,
140
+ u: sanitizeEventText(truncateUtf8(event.issueUrl, 256)),
141
+ ...(event.commented ? { c: 1 } : {}),
142
+ }
143
+ : event.kind === "refused"
144
+ ? {
145
+ k: "refused",
146
+ fp: event.fingerprint,
147
+ v: event.fpv,
148
+ r: event.recordId,
149
+ c: sanitizeEventText(truncateUtf8(event.contractVersion, 64)),
150
+ at: event.at,
151
+ }
152
+ : event.kind === "relayed"
153
+ ? { k: "relayed", fp: event.fingerprint, at: event.at, e: event.eventId, r: event.recordId }
154
+ : event.kind === "acknowledged"
155
+ ? { k: "acknowledged", fp: event.fingerprint, at: event.at }
156
+ : { k: "nudged", at: event.at };
157
+ return `${CRASH_EVENT_KIND} ${JSON.stringify(body)}\n`;
158
+ };
159
+
160
+ if (event.kind !== "occurrence") return truncateLine(build());
161
+ let preview = sanitizeEventText(truncateUtf8(event.messageClass, CRASH_EVENT_MESSAGE_MAX_BYTES));
162
+ let line = build(preview);
163
+ while (Buffer.byteLength(line, "utf8") > CRASH_EVENT_MAX_BYTES && preview.length > 0) {
164
+ preview = preview.slice(0, Math.floor(preview.length / 2));
165
+ line = build(preview);
166
+ }
167
+ if (Buffer.byteLength(line, "utf8") > CRASH_EVENT_MAX_BYTES) line = build();
168
+ return truncateLine(line);
169
+ }
170
+
171
+ /** Final safety net: an event line never exceeds the cap, even if malformed. */
172
+ function truncateLine(line: string): string {
173
+ if (Buffer.byteLength(line, "utf8") <= CRASH_EVENT_MAX_BYTES) return line;
174
+ return `${truncateUtf8(line.trimEnd(), CRASH_EVENT_MAX_BYTES - 1)}\n`;
175
+ }
176
+
177
+ function isSafeTimestamp(value: unknown): value is number {
178
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
179
+ }
180
+
181
+ /** Parse one journal line. Anything unexpected yields `undefined`, never a throw. */
182
+ export function parseCrashEventLine(line: string): CrashEvent | undefined {
183
+ const trimmed = line.trim();
184
+ if (!trimmed.startsWith(`${CRASH_EVENT_KIND} `)) return undefined;
185
+ if (Buffer.byteLength(trimmed, "utf8") > CRASH_EVENT_MAX_BYTES) return undefined;
186
+ let parsed: unknown;
187
+ try {
188
+ parsed = JSON.parse(trimmed.slice(CRASH_EVENT_KIND.length + 1)) as unknown;
189
+ } catch {
190
+ return undefined;
191
+ }
192
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
193
+ const body = parsed as Record<string, unknown>;
194
+ if (!isSafeTimestamp(body.at)) return undefined;
195
+ const at = body.at;
196
+ const fingerprint = typeof body.fp === "string" && CRASH_FINGERPRINT_PATTERN.test(body.fp) ? body.fp : undefined;
197
+
198
+ switch (body.k) {
199
+ case "occurrence": {
200
+ if (!fingerprint) return undefined;
201
+ if (typeof body.id !== "string" || !/^[0-9a-f]{8,32}$/.test(body.id)) return undefined;
202
+ if (typeof body.fpv !== "number" || !Number.isSafeInteger(body.fpv) || body.fpv < 1) return undefined;
203
+ const errorName = typeof body.n === "string" ? sanitizeEventText(body.n) : "Error";
204
+ const messageClass = typeof body.m === "string" ? sanitizeEventText(body.m) : "";
205
+ const provenance = body.p === undefined ? undefined : body.p;
206
+ if (provenance !== undefined && provenance !== "product" && provenance !== "eval" && provenance !== "bun_test")
207
+ return undefined;
208
+ return {
209
+ kind: "occurrence",
210
+ fingerprint,
211
+ fpv: body.fpv,
212
+ recordId: body.id,
213
+ at,
214
+ errorName,
215
+ messageClass,
216
+ ...(provenance === undefined ? {} : { provenance }),
217
+ };
218
+ }
219
+ case "reported": {
220
+ if (!fingerprint) return undefined;
221
+ if (typeof body.u !== "string" || body.u.length === 0) return undefined;
222
+ return { kind: "reported", fingerprint, at, issueUrl: sanitizeEventText(body.u), commented: body.c === 1 };
223
+ }
224
+ case "refused": {
225
+ if (!fingerprint) return undefined;
226
+ if (typeof body.r !== "string" || !/^[0-9a-f]{8,32}$/.test(body.r)) return undefined;
227
+ if (typeof body.v !== "number" || !Number.isSafeInteger(body.v) || body.v < 1) return undefined;
228
+ if (typeof body.c !== "string" || body.c.length === 0 || /[\u0000-\u001f\u007f-\u009f]/.test(body.c))
229
+ return undefined;
230
+ return { kind: "refused", fingerprint, fpv: body.v, recordId: body.r, contractVersion: body.c, at };
231
+ }
232
+ case "relayed": {
233
+ if (!fingerprint) return undefined;
234
+ if (typeof body.e !== "string" || !/^[0-9a-f]{32}$/.test(body.e)) return undefined;
235
+ if (body.r !== undefined && (typeof body.r !== "string" || !/^[0-9a-f]{8,32}$/.test(body.r))) return undefined;
236
+ return {
237
+ kind: "relayed",
238
+ fingerprint,
239
+ at,
240
+ eventId: body.e,
241
+ ...(typeof body.r === "string" ? { recordId: body.r } : {}),
242
+ };
243
+ }
244
+ case "acknowledged": {
245
+ if (!fingerprint) return undefined;
246
+ return { kind: "acknowledged", fingerprint, at };
247
+ }
248
+ case "nudged":
249
+ return { kind: "nudged", at };
250
+ default:
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Append one event synchronously with `O_APPEND`. Never throws.
257
+ *
258
+ * No parse, no lock, no rename, no read: the only I/O is one bounded write to
259
+ * an append-only file, so concurrent writers interleave whole lines instead of
260
+ * corrupting each other's records.
261
+ */
262
+ export function appendCrashEvent(event: CrashEvent, journalPath: string): boolean {
263
+ try {
264
+ const line = formatCrashEventLine(event);
265
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
266
+ const fd = fs.openSync(journalPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
267
+ try {
268
+ fs.writeSync(fd, line);
269
+ } finally {
270
+ fs.closeSync(fd);
271
+ }
272
+ return true;
273
+ } catch {
274
+ // A failing journal write must never mask or delay the original fatal.
275
+ return false;
276
+ }
277
+ }
278
+
279
+ let fatalJournalLatched = false;
280
+
281
+ /**
282
+ * Fatal-path entry point: at most one journal append per process lifetime.
283
+ *
284
+ * A crash raised while handling a crash must not spend the process's remaining
285
+ * moments on journal bookkeeping, so the latch is never cleared — the process
286
+ * is exiting anyway. Returns whether this call wrote a line.
287
+ */
288
+ export function appendFatalCrashEvent(event: CrashEvent, journalPath: string): boolean {
289
+ if (fatalJournalLatched) return false;
290
+ fatalJournalLatched = true;
291
+ return appendCrashEvent(event, journalPath);
292
+ }