@oh-my-pi/pi-utils 17.0.1 → 17.0.2
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 +15 -0
- package/dist/types/dirs.d.ts +2 -2
- package/dist/types/logger.d.ts +13 -0
- package/dist/types/postmortem.d.ts +7 -0
- package/dist/types/ptree.d.ts +3 -1
- package/package.json +2 -2
- package/src/dirs.ts +3 -3
- package/src/logger.ts +108 -5
- package/src/postmortem.ts +21 -16
- package/src/ptree.ts +25 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.2] - 2026-07-17
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a structured log sink API (`registerLogSink`, `LogEvent`, `LogLevel`) to the centralized logger, enabling out-of-band consumers (such as OpenTelemetry) to receive log events without affecting local file or console logging.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Bounded default `ptree.ChildProcess` stderr retention to a 32 KiB tail to prevent memory leaks in long-lived subprocesses. Full stderr capture must now be explicitly requested at spawn time using `{ stderr: "full" }` on `spawn` or `exec`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed fatal cleanup failing to reach `process.exit()` when terminal stderr is revoked.
|
|
18
|
+
- Isolated rotating log files and audit state per process to prevent concurrent instances from racing during compression and rotation.
|
|
19
|
+
|
|
5
20
|
## [17.0.1] - 2026-07-16
|
|
6
21
|
|
|
7
22
|
### Fixed
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -102,8 +102,8 @@ export declare function getProjectAgentDir(cwd?: string): string;
|
|
|
102
102
|
export declare function getReportsDir(): string;
|
|
103
103
|
/** Get the logs directory (~/.omp/logs). */
|
|
104
104
|
export declare function getLogsDir(): string;
|
|
105
|
-
/** Get
|
|
106
|
-
export declare function getLogPath(date?: Date): string;
|
|
105
|
+
/** Get this process's dated log path (~/.omp/logs/omp.YYYY-MM-DD.PID.log). */
|
|
106
|
+
export declare function getLogPath(date?: Date, pid?: number): string;
|
|
107
107
|
/**
|
|
108
108
|
* Get the plugins directory (~/.omp/plugins or its XDG equivalent).
|
|
109
109
|
*
|
package/dist/types/logger.d.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/** Severity names accepted by the centralized logger. */
|
|
2
|
+
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
3
|
+
/** Structured log event forwarded to out-of-band sinks such as OpenTelemetry. */
|
|
4
|
+
export interface LogEvent {
|
|
5
|
+
readonly level: LogLevel;
|
|
6
|
+
readonly message: string;
|
|
7
|
+
readonly context: Record<string, unknown> | undefined;
|
|
8
|
+
readonly timestamp: Date;
|
|
9
|
+
}
|
|
10
|
+
/** Receives each structured log event after the local transport path runs. */
|
|
11
|
+
export type LogSink = (event: LogEvent) => void;
|
|
12
|
+
/** Register an out-of-band log sink and return a disposer. */
|
|
13
|
+
export declare function registerLogSink(sink: LogSink): () => void;
|
|
1
14
|
/**
|
|
2
15
|
* Replace the active log transports. Pass `console: true, file: false` for
|
|
3
16
|
* long-running services (the auth broker, etc.) that want their structured
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cleanup and postmortem handler utilities.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a system for registering and running cleanup callbacks
|
|
5
|
+
* in response to process exit, signals, or fatal exceptions. It is intended to
|
|
6
|
+
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
|
+
*/
|
|
1
8
|
export declare enum Reason {
|
|
2
9
|
PRE_EXIT = "pre_exit",// Pre-exit phase (not used by default)
|
|
3
10
|
EXIT = "exit",// Normal process exit
|
package/dist/types/ptree.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare class TimeoutError extends AbortError {
|
|
|
40
40
|
export interface WaitOptions {
|
|
41
41
|
allowNonZero?: boolean;
|
|
42
42
|
allowAbort?: boolean;
|
|
43
|
+
/** `full` requires upfront capture; `exec` enables it, while direct `spawn` callers pass `stderr: "full"`. */
|
|
43
44
|
stderr?: "full" | "buffer";
|
|
44
45
|
}
|
|
45
46
|
/** Result from wait and exec. */
|
|
@@ -62,7 +63,7 @@ export declare class ChildProcess<In extends InMask = InMask> {
|
|
|
62
63
|
#private;
|
|
63
64
|
readonly proc: PipedSubprocess<In>;
|
|
64
65
|
readonly exposeStderr: boolean;
|
|
65
|
-
constructor(proc: PipedSubprocess<In>, exposeStderr: boolean);
|
|
66
|
+
constructor(proc: PipedSubprocess<In>, exposeStderr: boolean, retainFullStderr?: boolean);
|
|
66
67
|
get pid(): number;
|
|
67
68
|
get exited(): Promise<number>;
|
|
68
69
|
get exitCode(): number | null;
|
|
@@ -92,6 +93,7 @@ export declare class ChildProcess<In extends InMask = InMask> {
|
|
|
92
93
|
type ChildSpawnOptions<In extends InMask = InMask> = Omit<Spawn.SpawnOptions<In, "pipe", "pipe">, "stdout" | "stderr" | "detached"> & {
|
|
93
94
|
signal?: AbortSignal;
|
|
94
95
|
detached?: boolean;
|
|
96
|
+
/** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
|
|
95
97
|
stderr?: "full" | null;
|
|
96
98
|
};
|
|
97
99
|
/** Spawn a child process with piped stdout/stderr. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.2",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-natives": "17.0.
|
|
34
|
+
"@oh-my-pi/pi-natives": "17.0.2",
|
|
35
35
|
"handlebars": "^4.7.9",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"winston-daily-rotate-file": "^5.0.0"
|
package/src/dirs.ts
CHANGED
|
@@ -513,9 +513,9 @@ export function getLogsDir(): string {
|
|
|
513
513
|
return dirs.rootSubdir("logs", "state");
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
-
/** Get
|
|
517
|
-
export function getLogPath(date = new Date()): string {
|
|
518
|
-
return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
|
|
516
|
+
/** Get this process's dated log path (~/.omp/logs/omp.YYYY-MM-DD.PID.log). */
|
|
517
|
+
export function getLogPath(date = new Date(), pid = process.pid): string {
|
|
518
|
+
return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.${pid}.log`);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
/**
|
package/src/logger.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized logger for omp.
|
|
3
3
|
*
|
|
4
|
-
* Default: rotating `~/.omp/logs/omp.<DATE>.log`, no console output (writing
|
|
4
|
+
* Default: rotating `~/.omp/logs/omp.<DATE>.<PID>.log`, no console output (writing
|
|
5
5
|
* to stdout/stderr would corrupt the TUI). Long-running headless services
|
|
6
6
|
* (the auth broker, etc.) call {@link setTransports} to swap in a console
|
|
7
7
|
* transport so a process supervisor (pm2, journald, k8s) captures the logs.
|
|
@@ -11,11 +11,107 @@
|
|
|
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 { isPromise } from "node:util/types";
|
|
15
16
|
import winston from "winston";
|
|
16
17
|
import DailyRotateFile from "winston-daily-rotate-file";
|
|
17
18
|
import { getLogsDir } from "./dirs";
|
|
18
19
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
20
|
+
/** Severity names accepted by the centralized logger. */
|
|
21
|
+
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
22
|
+
|
|
23
|
+
/** Structured log event forwarded to out-of-band sinks such as OpenTelemetry. */
|
|
24
|
+
export interface LogEvent {
|
|
25
|
+
readonly level: LogLevel;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly context: Record<string, unknown> | undefined;
|
|
28
|
+
readonly timestamp: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Receives each structured log event after the local transport path runs. */
|
|
32
|
+
export type LogSink = (event: LogEvent) => void;
|
|
33
|
+
|
|
34
|
+
const logSinks = new Set<LogSink>();
|
|
35
|
+
|
|
36
|
+
/** Register an out-of-band log sink and return a disposer. */
|
|
37
|
+
export function registerLogSink(sink: LogSink): () => void {
|
|
38
|
+
logSinks.add(sink);
|
|
39
|
+
return () => {
|
|
40
|
+
logSinks.delete(sink);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emitToSinks(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
|
|
45
|
+
if (logSinks.size === 0) return;
|
|
46
|
+
const event: LogEvent = { level, message, context, timestamp: new Date() };
|
|
47
|
+
for (const sink of logSinks) {
|
|
48
|
+
try {
|
|
49
|
+
sink(event);
|
|
50
|
+
} catch {
|
|
51
|
+
// Sinks are side channels; they must never break local logging.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PROCESS_LOG_PATTERN = /^omp\.\d{4}-\d{2}-\d{2}\.(\d+)\.log(?:\.\d+)?$/;
|
|
57
|
+
const PROCESS_AUDIT_PATTERN = /^\.omp\.(\d+)-audit\.json$/;
|
|
58
|
+
const RETAINED_STALE_LOG_FILES = 5;
|
|
59
|
+
|
|
60
|
+
function processIsRunning(pid: number): boolean {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return !(error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL"));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Retain the newest completed-process logs globally and remove their one-use
|
|
71
|
+
* audit files. Live PID namespaces are never touched.
|
|
72
|
+
*/
|
|
73
|
+
function pruneStaleProcessLogs(dir: string): void {
|
|
74
|
+
let entries: fs.Dirent[];
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const staleLogs: Array<{ path: string; mtimeMs: number }> = [];
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.isFile()) continue;
|
|
84
|
+
const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
|
|
85
|
+
const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
|
|
86
|
+
const pidText = logMatch?.[1] ?? auditMatch?.[1];
|
|
87
|
+
if (!pidText || processIsRunning(Number(pidText))) continue;
|
|
88
|
+
const entryPath = path.join(dir, entry.name);
|
|
89
|
+
|
|
90
|
+
if (auditMatch) {
|
|
91
|
+
try {
|
|
92
|
+
fs.rmSync(entryPath, { force: true });
|
|
93
|
+
} catch {
|
|
94
|
+
// Retention is best-effort; logging must still initialize.
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
staleLogs.push({ path: entryPath, mtimeMs: fs.statSync(entryPath).mtimeMs });
|
|
101
|
+
} catch {
|
|
102
|
+
// Another process may have pruned the same stale namespace.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
staleLogs.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
107
|
+
for (const stale of staleLogs.slice(RETAINED_STALE_LOG_FILES)) {
|
|
108
|
+
try {
|
|
109
|
+
fs.rmSync(stale.path, { force: true });
|
|
110
|
+
} catch {
|
|
111
|
+
// Another process may have pruned the same stale namespace.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
19
115
|
|
|
20
116
|
/** Ensure a logs directory exists; return the resolved path. */
|
|
21
117
|
function ensureDir(dir: string): string {
|
|
@@ -72,15 +168,18 @@ function getLogFormat(): winston.Logform.Format {
|
|
|
72
168
|
return logFormat;
|
|
73
169
|
}
|
|
74
170
|
|
|
75
|
-
/** Build a rotating file transport
|
|
171
|
+
/** Build a rotating file transport with process-local rotation and shared retention. */
|
|
76
172
|
function makeFileTransport(dir?: string): winston.transport {
|
|
173
|
+
const logsDir = ensureDir(dir ?? getLogsDir());
|
|
174
|
+
pruneStaleProcessLogs(logsDir);
|
|
77
175
|
return new DailyRotateFile({
|
|
78
|
-
dirname:
|
|
79
|
-
filename:
|
|
176
|
+
dirname: logsDir,
|
|
177
|
+
filename: `omp.%DATE%.${process.pid}.log`,
|
|
80
178
|
datePattern: "YYYY-MM-DD",
|
|
81
179
|
maxSize: "10m",
|
|
82
180
|
maxFiles: 5,
|
|
83
|
-
zippedArchive:
|
|
181
|
+
zippedArchive: false,
|
|
182
|
+
auditFile: path.join(logsDir, `.omp.${process.pid}-audit.json`),
|
|
84
183
|
});
|
|
85
184
|
}
|
|
86
185
|
|
|
@@ -148,6 +247,7 @@ export function error(message: string, context?: Record<string, unknown>): void
|
|
|
148
247
|
} catch {
|
|
149
248
|
// Silently ignore logging failures
|
|
150
249
|
}
|
|
250
|
+
emitToSinks("error", message, context);
|
|
151
251
|
}
|
|
152
252
|
|
|
153
253
|
/**
|
|
@@ -161,6 +261,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
|
|
|
161
261
|
} catch {
|
|
162
262
|
// Silently ignore logging failures
|
|
163
263
|
}
|
|
264
|
+
emitToSinks("warn", message, context);
|
|
164
265
|
}
|
|
165
266
|
|
|
166
267
|
/**
|
|
@@ -174,6 +275,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
|
|
|
174
275
|
} catch {
|
|
175
276
|
// Silently ignore logging failures
|
|
176
277
|
}
|
|
278
|
+
emitToSinks("info", message, context);
|
|
177
279
|
}
|
|
178
280
|
|
|
179
281
|
/**
|
|
@@ -187,6 +289,7 @@ export function debug(message: string, context?: Record<string, unknown>): void
|
|
|
187
289
|
} catch {
|
|
188
290
|
// Silently ignore logging failures
|
|
189
291
|
}
|
|
292
|
+
emitToSinks("debug", message, context);
|
|
190
293
|
}
|
|
191
294
|
|
|
192
295
|
/**
|
package/src/postmortem.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* in response to process exit, signals, or fatal exceptions. It is intended to
|
|
6
6
|
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
7
|
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
8
10
|
import inspector from "node:inspector";
|
|
9
11
|
import { isMainThread } from "node:worker_threads";
|
|
10
12
|
import { logger } from ".";
|
|
@@ -68,7 +70,6 @@ function runCleanup(reason: Reason): Promise<void> {
|
|
|
68
70
|
cleanupStage = "complete";
|
|
69
71
|
deadline.resolve();
|
|
70
72
|
}, CLEANUP_DEADLINE_MS);
|
|
71
|
-
deadlineTimer.unref();
|
|
72
73
|
cleanupPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
|
|
73
74
|
clearTimeout(deadlineTimer);
|
|
74
75
|
});
|
|
@@ -175,6 +176,23 @@ function formatFatalError(label: string, err: Error): string {
|
|
|
175
176
|
return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
async function exitAfterFatal(label: string, logMessage: string, err: Error, reason: Reason): Promise<void> {
|
|
180
|
+
const forcedExit = setTimeout(() => process.exit(1), CLEANUP_DEADLINE_MS);
|
|
181
|
+
try {
|
|
182
|
+
restoreTerminalStderr();
|
|
183
|
+
// A revoked terminal can make stream writes raise another fatal error. Use
|
|
184
|
+
// the descriptor directly so failure stays synchronous and contained.
|
|
185
|
+
try {
|
|
186
|
+
fs.writeSync(2, formatFatalError(label, err));
|
|
187
|
+
} catch {}
|
|
188
|
+
logger.error(logMessage, { err });
|
|
189
|
+
await runCleanup(reason);
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(forcedExit);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
if (isMainThread) {
|
|
179
197
|
process
|
|
180
198
|
.on("SIGINT", async () => {
|
|
@@ -193,15 +211,7 @@ if (isMainThread) {
|
|
|
193
211
|
logger.warn("Ignoring expected cleanup exception", { err });
|
|
194
212
|
return;
|
|
195
213
|
}
|
|
196
|
-
|
|
197
|
-
// (stderr-guard); re-point it at the real terminal so the fatal
|
|
198
|
-
// report is visible. Terminal modes are restored moments later by
|
|
199
|
-
// the terminal-restore cleanup callback inside runCleanup().
|
|
200
|
-
restoreTerminalStderr();
|
|
201
|
-
process.stderr.write(formatFatalError("Uncaught Exception", err));
|
|
202
|
-
logger.error("Uncaught exception", { err });
|
|
203
|
-
await runCleanup(Reason.UNCAUGHT_EXCEPTION);
|
|
204
|
-
process.exit(1);
|
|
214
|
+
await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
|
|
205
215
|
})
|
|
206
216
|
.on("unhandledRejection", async reason => {
|
|
207
217
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
@@ -237,12 +247,7 @@ if (isMainThread) {
|
|
|
237
247
|
});
|
|
238
248
|
}
|
|
239
249
|
}
|
|
240
|
-
|
|
241
|
-
restoreTerminalStderr();
|
|
242
|
-
process.stderr.write(formatFatalError("Unhandled Rejection", err));
|
|
243
|
-
logger.error("Unhandled rejection", { err });
|
|
244
|
-
await runCleanup(Reason.UNHANDLED_REJECTION);
|
|
245
|
-
process.exit(1);
|
|
250
|
+
await exitAfterFatal("Unhandled Rejection", "Unhandled rejection", err, Reason.UNHANDLED_REJECTION);
|
|
246
251
|
})
|
|
247
252
|
.on("exit", async () => {
|
|
248
253
|
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
package/src/ptree.ts
CHANGED
|
@@ -72,6 +72,7 @@ export class TimeoutError extends AbortError {
|
|
|
72
72
|
export interface WaitOptions {
|
|
73
73
|
allowNonZero?: boolean;
|
|
74
74
|
allowAbort?: boolean;
|
|
75
|
+
/** `full` requires upfront capture; `exec` enables it, while direct `spawn` callers pass `stderr: "full"`. */
|
|
75
76
|
stderr?: "full" | "buffer";
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -97,7 +98,7 @@ export interface ExecResult {
|
|
|
97
98
|
export class ChildProcess<In extends InMask = InMask> {
|
|
98
99
|
#nothrow = false;
|
|
99
100
|
#stderrTail = "";
|
|
100
|
-
#stderrChunks
|
|
101
|
+
#stderrChunks?: Uint8Array[];
|
|
101
102
|
#exitReason?: Exception;
|
|
102
103
|
#exitReasonPending?: Exception;
|
|
103
104
|
#stderrDone: Promise<void>;
|
|
@@ -107,8 +108,10 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
107
108
|
constructor(
|
|
108
109
|
readonly proc: PipedSubprocess<In>,
|
|
109
110
|
readonly exposeStderr: boolean,
|
|
111
|
+
retainFullStderr = exposeStderr,
|
|
110
112
|
) {
|
|
111
|
-
|
|
113
|
+
if (retainFullStderr) this.#stderrChunks = [];
|
|
114
|
+
// Eagerly drain stderr into a truncated tail, retaining raw chunks only for explicit full capture.
|
|
112
115
|
const dec = new TextDecoder();
|
|
113
116
|
const trim = () => {
|
|
114
117
|
if (this.#stderrTail.length > NonZeroExitError.MAX_TRACE)
|
|
@@ -123,7 +126,7 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
123
126
|
this.#stderrDone = (async () => {
|
|
124
127
|
try {
|
|
125
128
|
for await (const chunk of stderrStream) {
|
|
126
|
-
this.#stderrChunks
|
|
129
|
+
this.#stderrChunks?.push(chunk);
|
|
127
130
|
this.#stderrTail += dec.decode(chunk, { stream: true });
|
|
128
131
|
trim();
|
|
129
132
|
}
|
|
@@ -259,11 +262,15 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
259
262
|
|
|
260
263
|
async wait(opts?: WaitOptions): Promise<ExecResult> {
|
|
261
264
|
const { allowNonZero = false, allowAbort = false, stderr: stderrMode = "buffer" } = opts ?? {};
|
|
265
|
+
const stderrChunks = this.#stderrChunks;
|
|
266
|
+
if (stderrMode === "full" && !stderrChunks) {
|
|
267
|
+
throw new Error('Full stderr capture must be requested when spawning the process (pass stderr: "full")');
|
|
268
|
+
}
|
|
262
269
|
|
|
263
270
|
const stdoutP = new Response(this.stdout).text();
|
|
264
271
|
const stderrP =
|
|
265
|
-
stderrMode === "full"
|
|
266
|
-
? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(
|
|
272
|
+
stderrMode === "full" && stderrChunks
|
|
273
|
+
? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(stderrChunks)))
|
|
267
274
|
: this.#stderrDone.then(() => this.#stderrTail);
|
|
268
275
|
|
|
269
276
|
const [stdout, stderr] = await Promise.all([stdoutP, stderrP]);
|
|
@@ -328,11 +335,15 @@ type ChildSpawnOptions<In extends InMask = InMask> = Omit<
|
|
|
328
335
|
> & {
|
|
329
336
|
signal?: AbortSignal;
|
|
330
337
|
detached?: boolean;
|
|
338
|
+
/** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
|
|
331
339
|
stderr?: "full" | null;
|
|
332
340
|
};
|
|
333
341
|
|
|
334
|
-
|
|
335
|
-
|
|
342
|
+
function spawnInternal<In extends InMask = InMask>(
|
|
343
|
+
cmd: string[],
|
|
344
|
+
opts: ChildSpawnOptions<In> | undefined,
|
|
345
|
+
retainFullStderr: boolean,
|
|
346
|
+
): ChildProcess<In> {
|
|
336
347
|
const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
|
|
337
348
|
const child = Bun.spawn(cmd, {
|
|
338
349
|
stdin: "ignore",
|
|
@@ -341,12 +352,17 @@ export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpa
|
|
|
341
352
|
windowsHide: true,
|
|
342
353
|
...rest,
|
|
343
354
|
});
|
|
344
|
-
const cp = new ChildProcess(child, stderr === "full");
|
|
355
|
+
const cp = new ChildProcess(child, stderr === "full", retainFullStderr);
|
|
345
356
|
if (signal) cp.attachSignal(signal);
|
|
346
357
|
if (timeout > 0) cp.attachTimeout(timeout);
|
|
347
358
|
return cp;
|
|
348
359
|
}
|
|
349
360
|
|
|
361
|
+
/** Spawn a child process with piped stdout/stderr. */
|
|
362
|
+
export function spawn<In extends InMask = InMask>(cmd: string[], opts?: ChildSpawnOptions<In>): ChildProcess<In> {
|
|
363
|
+
return spawnInternal(cmd, opts, opts?.stderr === "full");
|
|
364
|
+
}
|
|
365
|
+
|
|
350
366
|
/** Options for exec. */
|
|
351
367
|
export interface ExecOptions extends Omit<ChildSpawnOptions, "stderr" | "stdin">, WaitOptions {
|
|
352
368
|
input?: string | Buffer | Uint8Array;
|
|
@@ -357,7 +373,7 @@ export async function exec(cmd: string[], opts?: ExecOptions): Promise<ExecResul
|
|
|
357
373
|
const { input, stderr, allowAbort, allowNonZero, ...spawnOpts } = opts ?? {};
|
|
358
374
|
const stdin = typeof input === "string" ? Buffer.from(input) : input;
|
|
359
375
|
const resolved: ChildSpawnOptions = stdin === undefined ? spawnOpts : { ...spawnOpts, stdin };
|
|
360
|
-
using child =
|
|
376
|
+
using child = spawnInternal(cmd, resolved, stderr === "full");
|
|
361
377
|
return await child.wait({ stderr, allowAbort, allowNonZero });
|
|
362
378
|
}
|
|
363
379
|
|