@oh-my-pi/pi-utils 18.15.0 → 18.17.0
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/CHANGELOG.md +27 -0
- package/dist/types/acp/schema.d.ts +1 -1
- package/dist/types/ar/codecs/xz.d.ts +4 -0
- package/dist/types/ar/lzh.d.ts +2 -0
- package/dist/types/bytes.d.ts +7 -0
- package/dist/types/dirs.d.ts +6 -0
- package/dist/types/held-file.d.ts +5 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/logger.d.ts +29 -0
- package/dist/types/path.d.ts +2 -0
- package/dist/types/pid.d.ts +8 -0
- package/dist/types/procmgr.d.ts +1 -1
- package/dist/types/sqlite.d.ts +12 -0
- package/package.json +4 -3
- package/src/acp/schema.ts +53 -107
- package/src/ar/arj.ts +13 -32
- package/src/ar/asar.ts +5 -8
- package/src/ar/codecs/xz.ts +7 -8
- package/src/ar/lzh.ts +21 -27
- package/src/ar/sevenzip/decode.ts +1 -56
- package/src/ar/tar.ts +1 -8
- package/src/bytes.ts +14 -0
- package/src/dirs.ts +9 -0
- package/src/held-file.ts +22 -0
- package/src/index.ts +2 -0
- package/src/logger.ts +143 -29
- package/src/path.ts +14 -0
- package/src/pid.ts +16 -0
- package/src/postmortem.ts +9 -6
- package/src/procmgr.ts +1 -1
- package/src/ptree.ts +82 -18
- package/src/runtime-install.ts +1 -4
- package/src/sqlite.ts +32 -2
- package/src/stderr-guard.ts +18 -10
package/src/ar/lzh.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readUInt16LE, readUInt32LE } from "./bytes";
|
|
1
2
|
import { crc16Arc } from "./checksums";
|
|
2
3
|
import { ArchiveError } from "./error";
|
|
3
4
|
import { assertArchiveMemberSize, assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
|
|
@@ -279,16 +280,8 @@ function crc16WithZeroRange(bytes: Uint8Array, zeroStart: number, zeroEnd: numbe
|
|
|
279
280
|
return crc16Arc(bytes.subarray(zeroEnd), value);
|
|
280
281
|
}
|
|
281
282
|
|
|
282
|
-
function u16(bytes: Uint8Array, offset: number): number {
|
|
283
|
-
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function u32(bytes: Uint8Array, offset: number): number {
|
|
287
|
-
return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
283
|
function u64(bytes: Uint8Array, offset: number): number {
|
|
291
|
-
const value =
|
|
284
|
+
const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * 0x100000000;
|
|
292
285
|
if (!Number.isSafeInteger(value)) throw new ArchiveError("LZH uses sizes too large to read safely");
|
|
293
286
|
return value;
|
|
294
287
|
}
|
|
@@ -318,7 +311,8 @@ function decodeUtf16(bytes: Uint8Array): string {
|
|
|
318
311
|
return UTF16LE_DECODER.decode(bytes.subarray(0, end));
|
|
319
312
|
}
|
|
320
313
|
|
|
321
|
-
|
|
314
|
+
/** MS-DOS packed date/time as UTC milliseconds; `undefined` for 0 or an out-of-range field. */
|
|
315
|
+
export function dosTimeToMs(value: number): number | undefined {
|
|
322
316
|
if (value === 0) return undefined;
|
|
323
317
|
const year = 1980 + ((value >>> 25) & 0x7f);
|
|
324
318
|
const month = (value >>> 21) & 0x0f;
|
|
@@ -352,7 +346,7 @@ function processExtendedHeader(
|
|
|
352
346
|
switch (type) {
|
|
353
347
|
case 0x00:
|
|
354
348
|
if (data.byteLength < 2) throw new ArchiveError("Invalid LZH common extended header");
|
|
355
|
-
fields.commonCrc =
|
|
349
|
+
fields.commonCrc = readUInt16LE(data, 0);
|
|
356
350
|
fields.commonCrcOffset = absoluteDataOffset;
|
|
357
351
|
break;
|
|
358
352
|
case 0x01:
|
|
@@ -365,8 +359,8 @@ function processExtendedHeader(
|
|
|
365
359
|
throw new ArchiveError("Multi-volume LZH archives are unsupported");
|
|
366
360
|
case 0x41:
|
|
367
361
|
if (data.byteLength >= 16) {
|
|
368
|
-
const low =
|
|
369
|
-
const high =
|
|
362
|
+
const low = readUInt32LE(data, 8);
|
|
363
|
+
const high = readUInt32LE(data, 12);
|
|
370
364
|
const filetime = low + high * 0x100000000;
|
|
371
365
|
if (Number.isSafeInteger(filetime)) fields.mtimeMs = filetime / 10_000 - 11_644_473_600_000;
|
|
372
366
|
}
|
|
@@ -384,11 +378,11 @@ function processExtendedHeader(
|
|
|
384
378
|
break;
|
|
385
379
|
case 0x50:
|
|
386
380
|
if (data.byteLength < 2) throw new ArchiveError("Invalid LZH Unix permissions extended header");
|
|
387
|
-
fields.mode =
|
|
381
|
+
fields.mode = readUInt16LE(data, 0);
|
|
388
382
|
break;
|
|
389
383
|
case 0x54:
|
|
390
384
|
if (data.byteLength < 4) throw new ArchiveError("Invalid LZH Unix timestamp extended header");
|
|
391
|
-
fields.mtimeMs =
|
|
385
|
+
fields.mtimeMs = readUInt32LE(data, 0) * 1000;
|
|
392
386
|
break;
|
|
393
387
|
}
|
|
394
388
|
}
|
|
@@ -464,8 +458,8 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
464
458
|
if (level > 2) throw new ArchiveError(`Unsupported LZH header level ${level}`);
|
|
465
459
|
const method = String.fromCharCode(...bytes.subarray(offset + 2, offset + 7));
|
|
466
460
|
if (!LHA_METHOD_PATTERN.test(method)) throw new ArchiveError(`Invalid LZH compression method '${method}'`);
|
|
467
|
-
let packedSize =
|
|
468
|
-
let size =
|
|
461
|
+
let packedSize = readUInt32LE(bytes, offset + 7);
|
|
462
|
+
let size = readUInt32LE(bytes, offset + 11);
|
|
469
463
|
let crc = 0;
|
|
470
464
|
let legacyFilename: string | undefined;
|
|
471
465
|
let mtimeMs: number | undefined;
|
|
@@ -486,22 +480,22 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
486
480
|
if (22 + nameLength + 2 > headerLength + 2) throw new ArchiveError("Invalid LZH filename length");
|
|
487
481
|
assertArchivePathBytes(nameLength, "member path", options.limits.maxPathBytes);
|
|
488
482
|
legacyFilename = decodeLegacy(bytes.subarray(offset + 22, offset + 22 + nameLength));
|
|
489
|
-
crc =
|
|
490
|
-
mtimeMs = dosTimeToMs(
|
|
483
|
+
crc = readUInt16LE(bytes, offset + 22 + nameLength);
|
|
484
|
+
mtimeMs = dosTimeToMs(readUInt32LE(bytes, offset + 15));
|
|
491
485
|
if (level === 0) {
|
|
492
486
|
const extendedStart = offset + 24 + nameLength;
|
|
493
487
|
if (baseEnd - extendedStart >= 12) {
|
|
494
488
|
const extended = bytes.subarray(extendedStart, baseEnd);
|
|
495
489
|
if ((extended[0] === 0x55 || extended[0] === 0x4b) && extended[1] === 0) {
|
|
496
490
|
osId = extended[0]!;
|
|
497
|
-
fields.mtimeMs =
|
|
498
|
-
fields.mode =
|
|
491
|
+
fields.mtimeMs = readUInt32LE(extended, 2) * 1000;
|
|
492
|
+
fields.mode = readUInt16LE(extended, extended.byteLength - 6);
|
|
499
493
|
}
|
|
500
494
|
}
|
|
501
495
|
dataStart = baseEnd;
|
|
502
496
|
} else {
|
|
503
497
|
osId = bytes[offset + 24 + nameLength]!;
|
|
504
|
-
let extensionSize =
|
|
498
|
+
let extensionSize = readUInt16LE(bytes, baseEnd - 2);
|
|
505
499
|
let cursor = baseEnd;
|
|
506
500
|
let totalExtensionSize = 0;
|
|
507
501
|
let extensionCount = 0;
|
|
@@ -519,7 +513,7 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
519
513
|
}
|
|
520
514
|
processExtendedHeader(type, data, cursor + 1, fields);
|
|
521
515
|
const currentSize = extensionSize;
|
|
522
|
-
extensionSize =
|
|
516
|
+
extensionSize = readUInt16LE(bytes, dataEnd);
|
|
523
517
|
cursor += currentSize;
|
|
524
518
|
}
|
|
525
519
|
dataStart = baseEnd + totalExtensionSize;
|
|
@@ -527,17 +521,17 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
|
|
|
527
521
|
if (packedSize < 0) throw new ArchiveError("Invalid LZH level-1 packed size");
|
|
528
522
|
}
|
|
529
523
|
} else {
|
|
530
|
-
const headerLength =
|
|
524
|
+
const headerLength = readUInt16LE(bytes, offset);
|
|
531
525
|
if (headerLength < 26) throw new ArchiveError("Invalid LZH level-2 header size");
|
|
532
526
|
const headerEnd = offset + headerLength;
|
|
533
527
|
assertRange(bytes, offset, headerEnd, "header");
|
|
534
|
-
crc =
|
|
528
|
+
crc = readUInt16LE(bytes, offset + 21);
|
|
535
529
|
osId = bytes[offset + 23]!;
|
|
536
|
-
mtimeMs =
|
|
530
|
+
mtimeMs = readUInt32LE(bytes, offset + 15) * 1000;
|
|
537
531
|
let cursor = offset + 24;
|
|
538
532
|
let extensionCount = 0;
|
|
539
533
|
while (cursor + 2 <= headerEnd) {
|
|
540
|
-
const extensionSize =
|
|
534
|
+
const extensionSize = readUInt16LE(bytes, cursor);
|
|
541
535
|
if (extensionSize === 0) {
|
|
542
536
|
cursor += 2;
|
|
543
537
|
break;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { crc32 } from "../checksums";
|
|
2
2
|
import { lzma2Decompress, lzmaDecompress } from "../codecs/lzma";
|
|
3
|
+
import { deltaDecode, x86Decode } from "../codecs/xz";
|
|
3
4
|
import { ArchiveError } from "../error";
|
|
4
5
|
import type { ArchiveLimits } from "../limits";
|
|
5
6
|
import { assertInMemorySize } from "../limits";
|
|
@@ -81,62 +82,6 @@ function read32LE(bytes: Uint8Array, offset: number): number {
|
|
|
81
82
|
return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function deltaDecode(bytes: Uint8Array, distance: number): void {
|
|
85
|
-
const history = new Uint8Array(256);
|
|
86
|
-
let position = 0;
|
|
87
|
-
for (let index = 0; index < bytes.byteLength; index++) {
|
|
88
|
-
const value = (bytes[index]! + history[(distance + position) & 0xff]!) & 0xff;
|
|
89
|
-
history[position] = value;
|
|
90
|
-
bytes[index] = value;
|
|
91
|
-
position = (position - 1) & 0xff;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function x86Decode(bytes: Uint8Array, startOffset: number): void {
|
|
96
|
-
const maskToBitNumber = [0, 1, 2, 2, 3] as const;
|
|
97
|
-
let previousMask = 0;
|
|
98
|
-
let previousPosition = 0xfffffffb;
|
|
99
|
-
if (bytes.byteLength < 5) return;
|
|
100
|
-
let position = 0;
|
|
101
|
-
const limit = bytes.byteLength - 5;
|
|
102
|
-
while (position <= limit) {
|
|
103
|
-
let byte = bytes[position]!;
|
|
104
|
-
if (byte !== 0xe8 && byte !== 0xe9) {
|
|
105
|
-
position++;
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
const absolutePosition = (startOffset + position) >>> 0;
|
|
109
|
-
const offset = (absolutePosition - previousPosition) >>> 0;
|
|
110
|
-
previousPosition = absolutePosition;
|
|
111
|
-
if (offset > 5) previousMask = 0;
|
|
112
|
-
else for (let index = 0; index < offset; index++) previousMask = ((previousMask & 0x77) << 1) >>> 0;
|
|
113
|
-
byte = bytes[position + 4]!;
|
|
114
|
-
if ((byte === 0 || byte === 0xff) && previousMask >>> 1 <= 4 && previousMask >>> 1 !== 3) {
|
|
115
|
-
let source = read32LE(bytes, position + 1);
|
|
116
|
-
let destination = 0;
|
|
117
|
-
for (;;) {
|
|
118
|
-
destination = (source - absolutePosition - 5) >>> 0;
|
|
119
|
-
if (previousMask === 0) break;
|
|
120
|
-
const bitIndex = maskToBitNumber[previousMask >>> 1]! * 8;
|
|
121
|
-
const testByte = (destination >>> (24 - bitIndex)) & 0xff;
|
|
122
|
-
if (testByte !== 0 && testByte !== 0xff) break;
|
|
123
|
-
const lowMask = bitIndex === 0 ? 0xffffffff : 2 ** (32 - bitIndex) - 1;
|
|
124
|
-
source = (destination ^ lowMask) >>> 0;
|
|
125
|
-
}
|
|
126
|
-
bytes[position + 4] = ~(((destination >>> 24) & 1) - 1) & 0xff;
|
|
127
|
-
bytes[position + 3] = destination >>> 16;
|
|
128
|
-
bytes[position + 2] = destination >>> 8;
|
|
129
|
-
bytes[position + 1] = destination;
|
|
130
|
-
position += 5;
|
|
131
|
-
previousMask = 0;
|
|
132
|
-
} else {
|
|
133
|
-
position++;
|
|
134
|
-
previousMask |= 1;
|
|
135
|
-
if (byte === 0 || byte === 0xff) previousMask |= 0x10;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
85
|
/** Shared lazy decoder/cache for a single 7z folder (solid block). */
|
|
141
86
|
export class SevenZipFolderSource {
|
|
142
87
|
readonly #source: ByteSource;
|
package/src/ar/tar.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { bytesMatchAscii } from "./bytes";
|
|
2
|
+
import { bytesMatchAscii, indexOfAscii } from "./bytes";
|
|
3
3
|
import { resolveArchiveLinkPath, upsertArchiveEntry } from "./entries";
|
|
4
4
|
import { ArchiveError } from "./error";
|
|
5
5
|
import {
|
|
@@ -419,13 +419,6 @@ function paxDeclaresSparse(
|
|
|
419
419
|
return paxAttribute(globalPax, localPax, PAX_SPARSE_MARKER) === "1";
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
-
function indexOfAscii(bytes: Uint8Array, value: string, start: number): number {
|
|
423
|
-
for (let offset = start; offset <= bytes.byteLength - value.length; offset++) {
|
|
424
|
-
if (bytesMatchAscii(bytes, offset, value)) return offset;
|
|
425
|
-
}
|
|
426
|
-
return -1;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
422
|
function normalizeOldGnuName(value: string, field: string, limits: ArchiveLimits): string {
|
|
430
423
|
const portable = value.replace(/\\/g, "/");
|
|
431
424
|
if (path.posix.isAbsolute(portable)) throw new ArchiveError(`Invalid old-GNU ${field}`);
|
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow `bytes` to a view over its own whole `ArrayBuffer`, which is what
|
|
3
|
+
* `crypto.subtle.{digest,sign,importKey,encrypt,decrypt}` require under the
|
|
4
|
+
* strict TS DOM typings. Returns `bytes` itself when it already is one,
|
|
5
|
+
* otherwise a copy.
|
|
6
|
+
*/
|
|
7
|
+
export function asStrictBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
8
|
+
if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
|
|
9
|
+
return bytes as Uint8Array<ArrayBuffer>;
|
|
10
|
+
}
|
|
11
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
12
|
+
copy.set(bytes);
|
|
13
|
+
return copy;
|
|
14
|
+
}
|
package/src/dirs.ts
CHANGED
|
@@ -656,6 +656,15 @@ export function getReportsDir(): string {
|
|
|
656
656
|
return dirs.rootSubdir("reports", "state");
|
|
657
657
|
}
|
|
658
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Get the self-update state directory (~/.omp/update): the background
|
|
661
|
+
* auto-update's status file and worker log, and the lock that keeps two
|
|
662
|
+
* installs (`omp update`, auto-update) from running at once.
|
|
663
|
+
*/
|
|
664
|
+
export function getUpdateStateDir(): string {
|
|
665
|
+
return dirs.rootSubdir("update", "state");
|
|
666
|
+
}
|
|
667
|
+
|
|
659
668
|
/**
|
|
660
669
|
* Get the logs directory (~/.omp/logs). `bun test` processes get
|
|
661
670
|
* `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
|
package/src/held-file.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuses overwriting a file this process holds open with locks (issue #15586).
|
|
3
|
+
*
|
|
4
|
+
* Writing over a live `agent.db` or its `-shm` destroys the store (a truncated
|
|
5
|
+
* `-shm` also faults the connection's memory map), so tools that write
|
|
6
|
+
* agent-chosen paths check here first.
|
|
7
|
+
*/
|
|
8
|
+
import { heldFileKind } from "@oh-my-pi/pi-natives/sqlite-locks";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Throws an `EBUSY` error when `filePath` is a file this process holds open with
|
|
12
|
+
* locks: a SQLite database or `-shm` of a live connection. Always passes outside Linux.
|
|
13
|
+
*/
|
|
14
|
+
export function assertNotHeldOpen(filePath: string): void {
|
|
15
|
+
const kind = heldFileKind(filePath);
|
|
16
|
+
if (!kind) return;
|
|
17
|
+
const holder = kind === "locked" ? "a file lock" : "a SQLite connection";
|
|
18
|
+
throw Object.assign(
|
|
19
|
+
new Error(`EBUSY: '${filePath}' is held open by ${holder} in this process; overwriting it would destroy it`),
|
|
20
|
+
{ code: "EBUSY", path: filePath },
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { once, untilAborted } from "./abortable";
|
|
2
2
|
export * from "./async";
|
|
3
3
|
export * from "./binary";
|
|
4
|
+
export * from "./bytes";
|
|
4
5
|
export * from "./color";
|
|
5
6
|
export * from "./dirs";
|
|
6
7
|
export * from "./env";
|
|
@@ -22,6 +23,7 @@ export * from "./mime";
|
|
|
22
23
|
export * from "./path";
|
|
23
24
|
export * from "./path-tree";
|
|
24
25
|
export * from "./peek-file";
|
|
26
|
+
export * from "./pid";
|
|
25
27
|
export * as postmortem from "./postmortem";
|
|
26
28
|
export * from "./process-name";
|
|
27
29
|
export * as procmgr from "./procmgr";
|
package/src/logger.ts
CHANGED
|
@@ -22,8 +22,10 @@ import * as path from "node:path";
|
|
|
22
22
|
import { isPromise } from "node:util/types";
|
|
23
23
|
import { getLogsDir, localDay } from "./dirs";
|
|
24
24
|
import { RotatingFileSink } from "./logger/rotating-file";
|
|
25
|
+
import { isPidAlive } from "./pid";
|
|
25
26
|
import { setStderrRedirectTarget } from "./stderr-guard";
|
|
26
27
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
28
|
+
import { isRecord } from "./type-guards";
|
|
27
29
|
/** Severity names accepted by the centralized logger. */
|
|
28
30
|
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
29
31
|
|
|
@@ -38,8 +40,103 @@ export interface LogEvent {
|
|
|
38
40
|
/** Receives each structured log event after the local transport path runs. */
|
|
39
41
|
export type LogSink = (event: LogEvent) => void;
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Destination that takes this thread's log records in place of its own
|
|
45
|
+
* transports and sinks (a worker thread routing records to the main thread's
|
|
46
|
+
* logger); see {@link setLogForwarder}.
|
|
47
|
+
*/
|
|
48
|
+
export interface LogForwarder {
|
|
49
|
+
/**
|
|
50
|
+
* Take one record. Its context is already merged over the enclosing
|
|
51
|
+
* {@link withContext} fields and reduced to plain JSON data, so it survives
|
|
52
|
+
* a structured clone; `timestamp` is when the record was logged.
|
|
53
|
+
*/
|
|
54
|
+
emit(event: LogEvent): void;
|
|
55
|
+
/** Write every record emitted so far on the receiving side; backs {@link flush}. */
|
|
56
|
+
flush(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
const logSinks = new Set<LogSink>();
|
|
42
60
|
|
|
61
|
+
/** Set by {@link setLogForwarder}: records go there instead of to local transports and sinks. */
|
|
62
|
+
let logForwarder: LogForwarder | undefined;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Route every later record logged on this thread to `forwarder` instead of the
|
|
66
|
+
* local transports and sinks, which then stay unused (no log file is opened);
|
|
67
|
+
* {@link flush} asks the forwarder to flush. `undefined` restores local logging.
|
|
68
|
+
* For worker threads, whose main thread owns the process log file.
|
|
69
|
+
*/
|
|
70
|
+
export function setLogForwarder(forwarder: LogForwarder | undefined): void {
|
|
71
|
+
logForwarder = forwarder;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Log a record another thread forwarded (see {@link setLogForwarder}): written
|
|
76
|
+
* to the local transports with its own timestamp, then handed to the sinks. Its
|
|
77
|
+
* context is taken as is; the caller's enclosing {@link withContext} is not
|
|
78
|
+
* merged in. On a thread that forwards itself, the record is forwarded on.
|
|
79
|
+
*/
|
|
80
|
+
export function emitForeign(event: LogEvent): void {
|
|
81
|
+
const forwarder = logForwarder;
|
|
82
|
+
if (forwarder) {
|
|
83
|
+
try {
|
|
84
|
+
forwarder.emit(event);
|
|
85
|
+
} catch {
|
|
86
|
+
// Forwarding is as best-effort as local writes.
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
emitLocally(event.level, event.message, event.context, event.timestamp);
|
|
92
|
+
} catch {
|
|
93
|
+
// Silently ignore logging failures
|
|
94
|
+
}
|
|
95
|
+
dispatchToSinks(event);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Forward one record logged on this thread; never throws. */
|
|
99
|
+
function forwardRecord(
|
|
100
|
+
forwarder: LogForwarder,
|
|
101
|
+
level: LogLevel,
|
|
102
|
+
message: string,
|
|
103
|
+
context: Record<string, unknown> | undefined,
|
|
104
|
+
): void {
|
|
105
|
+
try {
|
|
106
|
+
forwarder.emit({ level, message, context: forwardableContext(context), timestamp: new Date() });
|
|
107
|
+
} catch {
|
|
108
|
+
// Forwarding is as best-effort as local writes.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The call's context merged over the enclosing {@link withContext} fields (call
|
|
114
|
+
* fields win, as for sinks), as plain JSON data: errors become
|
|
115
|
+
* `{ name, message, stack, … }` exactly as in the log file. A context that
|
|
116
|
+
* cannot be serialized (a cycle, a bigint) is dropped from the record.
|
|
117
|
+
*/
|
|
118
|
+
function forwardableContext(context: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
119
|
+
// An Error passed as the context carries its fields as non-enumerable properties.
|
|
120
|
+
let merged = context instanceof Error ? errorToRecord(context) : context;
|
|
121
|
+
const scoped = logContextStorage.getStore();
|
|
122
|
+
if (scoped) {
|
|
123
|
+
try {
|
|
124
|
+
merged = { ...scoped, ...merged };
|
|
125
|
+
} catch {
|
|
126
|
+
// A throwing getter must not turn a log call into a failure; keep the call's own fields.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (merged === undefined) return undefined;
|
|
130
|
+
try {
|
|
131
|
+
const parsed: unknown = JSON.parse(
|
|
132
|
+
mayContainError(merged) ? JSON.stringify(merged, jsonReplacer) : JSON.stringify(merged),
|
|
133
|
+
);
|
|
134
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
43
140
|
/** Register an out-of-band log sink and return a disposer. */
|
|
44
141
|
export function registerLogSink(sink: LogSink): () => void {
|
|
45
142
|
logSinks.add(sink);
|
|
@@ -61,7 +158,11 @@ function emitToSinks(level: LogLevel, message: string, context: Record<string, u
|
|
|
61
158
|
// A throwing getter must not turn a log call into a failure; sinks get the call's own fields.
|
|
62
159
|
}
|
|
63
160
|
}
|
|
64
|
-
|
|
161
|
+
dispatchToSinks({ level, message, context: merged, timestamp: new Date() });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function dispatchToSinks(event: LogEvent): void {
|
|
165
|
+
if (logSinks.size === 0) return;
|
|
65
166
|
for (const sink of logSinks) {
|
|
66
167
|
try {
|
|
67
168
|
sink(event);
|
|
@@ -81,15 +182,6 @@ const LEGACY_AUDIT_PATTERN = /^\.[0-9a-f]{8,}-audit\.json$/;
|
|
|
81
182
|
const RETAINED_STALE_LOGS_PER_PROCESS_DAY = 1;
|
|
82
183
|
const RETAINED_STALE_LOG_DAYS = 5;
|
|
83
184
|
|
|
84
|
-
function processIsRunning(pid: number): boolean {
|
|
85
|
-
try {
|
|
86
|
-
process.kill(pid, 0);
|
|
87
|
-
return true;
|
|
88
|
-
} catch (error) {
|
|
89
|
-
return !(error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL"));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
185
|
/**
|
|
94
186
|
* Retain one newest completed-process log per process/day within the current
|
|
95
187
|
* and previous four local calendar days, remove completed-process audit files
|
|
@@ -133,7 +225,7 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
133
225
|
const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
|
|
134
226
|
const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
|
|
135
227
|
const pidText = logMatch?.[2] ?? auditMatch?.[1];
|
|
136
|
-
if (!pidText ||
|
|
228
|
+
if (!pidText || isPidAlive(Number(pidText))) continue;
|
|
137
229
|
|
|
138
230
|
if (auditMatch) {
|
|
139
231
|
removeBestEffort(entryPath);
|
|
@@ -207,19 +299,21 @@ function ensureDir(dir: string): string {
|
|
|
207
299
|
* forensic logs showed only an opaque empty object.
|
|
208
300
|
*/
|
|
209
301
|
function jsonReplacer(_key: string, value: unknown): unknown {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
302
|
+
return value instanceof Error ? errorToRecord(value) : value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** An Error's fields as a plain object: name, message, stack, enumerable extras, and `cause`. */
|
|
306
|
+
function errorToRecord(value: Error): Record<string, unknown> {
|
|
307
|
+
const out: Record<string, unknown> = {
|
|
308
|
+
name: value.name,
|
|
309
|
+
message: value.message,
|
|
310
|
+
stack: value.stack,
|
|
311
|
+
};
|
|
312
|
+
// Preserve `.cause` and any custom enumerable fields the caller attached.
|
|
313
|
+
const errAsRecord = value as unknown as Record<string, unknown>;
|
|
314
|
+
for (const k in errAsRecord) out[k] = errAsRecord[k];
|
|
315
|
+
if (value.cause !== undefined) out.cause = value.cause;
|
|
316
|
+
return out;
|
|
223
317
|
}
|
|
224
318
|
|
|
225
319
|
// Values visited by the Error pre-scan before falling back to the replacer.
|
|
@@ -286,12 +380,16 @@ export function withoutContext<T>(fn: () => T): T {
|
|
|
286
380
|
return logContextStorage.exit(fn);
|
|
287
381
|
}
|
|
288
382
|
|
|
289
|
-
/**
|
|
383
|
+
/**
|
|
384
|
+
* Serialize one log record: timestamp, level, pid, message, then the `scoped`
|
|
385
|
+
* ({@link withContext}) fields and the context fields.
|
|
386
|
+
*/
|
|
290
387
|
function formatLogRecord(
|
|
291
388
|
level: LogLevel,
|
|
292
389
|
message: string,
|
|
293
390
|
context: Record<string, unknown> | undefined,
|
|
294
391
|
timestamp: string,
|
|
392
|
+
scoped: Readonly<Record<string, unknown>> | undefined,
|
|
295
393
|
): string {
|
|
296
394
|
const metadata =
|
|
297
395
|
!FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
|
|
@@ -301,7 +399,6 @@ function formatLogRecord(
|
|
|
301
399
|
pid: process.pid,
|
|
302
400
|
message: metadata?.message ? `${message} ${metadata.message}` : message,
|
|
303
401
|
};
|
|
304
|
-
const scoped = logContextStorage.getStore();
|
|
305
402
|
if (scoped) {
|
|
306
403
|
for (const key of Object.keys(scoped)) {
|
|
307
404
|
if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = scoped[key];
|
|
@@ -378,7 +475,8 @@ function resolveFileLevelRank(): number {
|
|
|
378
475
|
*/
|
|
379
476
|
export function flush(): void {
|
|
380
477
|
try {
|
|
381
|
-
|
|
478
|
+
if (logForwarder) logForwarder.flush();
|
|
479
|
+
else activeTransports?.file?.flush();
|
|
382
480
|
} catch {
|
|
383
481
|
// Flushing diagnostics must never mask the caller's own work or failure.
|
|
384
482
|
}
|
|
@@ -413,13 +511,25 @@ function getLocalTransports(): LocalTransports {
|
|
|
413
511
|
return activeTransports;
|
|
414
512
|
}
|
|
415
513
|
|
|
416
|
-
|
|
514
|
+
/**
|
|
515
|
+
* Write one record to the local transports. A `foreignTimestamp` marks a record
|
|
516
|
+
* another thread logged ({@link emitForeign}): it keeps that time and gets no
|
|
517
|
+
* {@link withContext} fields of this call stack, while the file still rotates by
|
|
518
|
+
* the current day so a late record cannot reopen an earlier day's file.
|
|
519
|
+
*/
|
|
520
|
+
function emitLocally(
|
|
521
|
+
level: LogLevel,
|
|
522
|
+
message: string,
|
|
523
|
+
context: Record<string, unknown> | undefined,
|
|
524
|
+
foreignTimestamp?: Date,
|
|
525
|
+
): void {
|
|
417
526
|
const transports = getLocalTransports();
|
|
418
527
|
const rank = LOG_LEVEL_RANK[level];
|
|
419
528
|
const file = rank <= transports.fileLevelRank ? transports.file : undefined;
|
|
420
529
|
if (!file && !transports.console) return;
|
|
421
530
|
const now = new Date();
|
|
422
|
-
const
|
|
531
|
+
const scoped = foreignTimestamp ? undefined : logContextStorage.getStore();
|
|
532
|
+
const line = formatLogRecord(level, message, context, formatLocalTimestamp(foreignTimestamp ?? now), scoped);
|
|
423
533
|
file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
|
|
424
534
|
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
425
535
|
}
|
|
@@ -446,6 +556,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
|
|
|
446
556
|
* @param context - The context to log.
|
|
447
557
|
*/
|
|
448
558
|
export function error(message: string, context?: Record<string, unknown>): void {
|
|
559
|
+
if (logForwarder) return forwardRecord(logForwarder, "error", message, context);
|
|
449
560
|
try {
|
|
450
561
|
emitLocally("error", message, context);
|
|
451
562
|
} catch {
|
|
@@ -460,6 +571,7 @@ export function error(message: string, context?: Record<string, unknown>): void
|
|
|
460
571
|
* @param context - The context to log.
|
|
461
572
|
*/
|
|
462
573
|
export function warn(message: string, context?: Record<string, unknown>): void {
|
|
574
|
+
if (logForwarder) return forwardRecord(logForwarder, "warn", message, context);
|
|
463
575
|
try {
|
|
464
576
|
emitLocally("warn", message, context);
|
|
465
577
|
} catch {
|
|
@@ -474,6 +586,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
|
|
|
474
586
|
* @param context - The context to log.
|
|
475
587
|
*/
|
|
476
588
|
export function info(message: string, context?: Record<string, unknown>): void {
|
|
589
|
+
if (logForwarder) return forwardRecord(logForwarder, "info", message, context);
|
|
477
590
|
try {
|
|
478
591
|
emitLocally("info", message, context);
|
|
479
592
|
} catch {
|
|
@@ -488,6 +601,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
|
|
|
488
601
|
* @param context - The context to log.
|
|
489
602
|
*/
|
|
490
603
|
export function debug(message: string, context?: Record<string, unknown>): void {
|
|
604
|
+
if (logForwarder) return forwardRecord(logForwarder, "debug", message, context);
|
|
491
605
|
try {
|
|
492
606
|
emitLocally("debug", message, context);
|
|
493
607
|
} catch {
|
package/src/path.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
|
|
3
4
|
const WINDOWS_DRIVE_PATH = /^([A-Za-z]):[\\/](.*)$/;
|
|
@@ -54,3 +55,16 @@ export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform
|
|
|
54
55
|
}
|
|
55
56
|
return true;
|
|
56
57
|
}
|
|
58
|
+
|
|
59
|
+
/** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
|
|
60
|
+
export function expandTilde(filePath: string, home?: string): string {
|
|
61
|
+
const h = home ?? os.homedir();
|
|
62
|
+
if (filePath === "~") return h;
|
|
63
|
+
if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
|
|
64
|
+
return h + filePath.slice(1);
|
|
65
|
+
}
|
|
66
|
+
if (filePath.startsWith("~")) {
|
|
67
|
+
return path.join(h, filePath.slice(1));
|
|
68
|
+
}
|
|
69
|
+
return filePath;
|
|
70
|
+
}
|
package/src/pid.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal-0 liveness probe: true unless the OS reports the pid as gone
|
|
3
|
+
* (`ESRCH`) or invalid (`EINVAL`). Any other failure, notably `EPERM` (alive
|
|
4
|
+
* but owned by another user), reads as alive, so callers never reclaim a
|
|
5
|
+
* resource a live process may still hold. Unlike `procmgr.isPidRunning`, this
|
|
6
|
+
* does not consult the process table's run state.
|
|
7
|
+
*/
|
|
8
|
+
export function isPidAlive(pid: number): boolean {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
} catch (error) {
|
|
13
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
14
|
+
return code !== "ESRCH" && code !== "EINVAL";
|
|
15
|
+
}
|
|
16
|
+
}
|