@oh-my-pi/pi-utils 17.1.7 → 17.2.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 +10 -0
- package/dist/types/env.d.ts +4 -5
- package/dist/types/index.d.ts +1 -0
- package/dist/types/postmortem.d.ts +7 -2
- package/dist/types/process-name.d.ts +7 -0
- package/package.json +3 -3
- package/src/env.ts +110 -27
- package/src/index.ts +1 -0
- package/src/logger.ts +104 -65
- package/src/postmortem.ts +11 -5
- package/src/process-name.ts +57 -0
- package/src/winston-daily-rotate-file.d.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.8] - 2026-07-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `setProcessName` utility to set the OS-visible process name on Linux via `bun:ffi`, bypassing Bun's `process.title` limitations.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed child shell environment filtering to drop launch-directory `.env` values in addition to Bun-autoloaded `.env.local` values.
|
|
14
|
+
|
|
5
15
|
## [17.1.5] - 2026-07-27
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/dist/types/env.d.ts
CHANGED
|
@@ -17,13 +17,12 @@ export declare function isSafeEnvName(name: string): boolean;
|
|
|
17
17
|
export declare function isSafeEnvValue(value: string): boolean;
|
|
18
18
|
export declare function isMacosMallocStackLoggingEnvName(name: string): boolean;
|
|
19
19
|
export declare function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string>;
|
|
20
|
-
/** Filters process env for child shells without launch-cwd
|
|
20
|
+
/** Filters process env for child shells without launch-cwd dotenv values. */
|
|
21
21
|
export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
|
|
22
22
|
/**
|
|
23
|
-
* Parses a .env file synchronously
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Returns an object of key-value pairs.
|
|
23
|
+
* Parses a .env file synchronously into key-value string pairs using
|
|
24
|
+
* {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid
|
|
25
|
+
* `OMP_` variables to their `PI_` aliases.
|
|
27
26
|
*/
|
|
28
27
|
export declare function parseEnvFile(filePath: string): Record<string, string>;
|
|
29
28
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from "./path.js";
|
|
|
19
19
|
export * from "./path-tree.js";
|
|
20
20
|
export * from "./peek-file.js";
|
|
21
21
|
export * as postmortem from "./postmortem.js";
|
|
22
|
+
export * from "./process-name.js";
|
|
22
23
|
export * as procmgr from "./procmgr.js";
|
|
23
24
|
export * as prompt from "./prompt.js";
|
|
24
25
|
export * as ptree from "./ptree.js";
|
|
@@ -64,10 +64,15 @@ export declare function register(id: string, callback: (reason: Reason) => void
|
|
|
64
64
|
* Use this in workers or when you need to clean up but continue execution.
|
|
65
65
|
*/
|
|
66
66
|
export declare function cleanup(): Promise<void>;
|
|
67
|
+
/** Controls how manual process shutdown handles terminal output. */
|
|
68
|
+
export interface QuitOptions {
|
|
69
|
+
/** Wait for buffered stdout before exiting; disable after the terminal has disconnected. */
|
|
70
|
+
drainStdout?: boolean;
|
|
71
|
+
}
|
|
67
72
|
/**
|
|
68
73
|
* Runs all cleanup callbacks and exits through the current `process.exit`.
|
|
69
74
|
*
|
|
70
|
-
* In main thread: waits for stdout drain, then calls `process.exit()`.
|
|
75
|
+
* In main thread: waits for stdout drain unless disabled, then calls `process.exit()`.
|
|
71
76
|
* In workers: runs cleanup only (process.exit would kill entire process).
|
|
72
77
|
*/
|
|
73
|
-
export declare function quit(code?: number): Promise<void>;
|
|
78
|
+
export declare function quit(code?: number, options?: QuitOptions): Promise<void>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set both the JS `process.title` and — on Linux — the kernel `comm` name.
|
|
3
|
+
*
|
|
4
|
+
* Never throws: `bun:ffi` unavailability or a failed syscall degrades silently
|
|
5
|
+
* to the `process.title`-only behavior, so it is safe to call at startup.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setProcessName(name: string): void;
|
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.
|
|
4
|
+
"version": "17.2.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-natives": "17.
|
|
34
|
+
"@oh-my-pi/pi-natives": "17.2.0",
|
|
35
35
|
"handlebars": "^4.7.9",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
|
-
"winston-daily-rotate-file": "
|
|
37
|
+
"winston-daily-rotate-file": "5.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.14"
|
package/src/env.ts
CHANGED
|
@@ -52,50 +52,132 @@ export function filterProcessEnv(env: Record<string, string | undefined>): Recor
|
|
|
52
52
|
}
|
|
53
53
|
return result;
|
|
54
54
|
}
|
|
55
|
+
// Bun autoloads the project's dotenv files into `process.env` before user code
|
|
56
|
+
// runs — including inside `bun build --compile` binaries — so a snapshot of
|
|
57
|
+
// `Bun.env` is only pre-dotenv when autoloading was explicitly disabled. Linux
|
|
58
|
+
// keeps the original exec environment in procfs, which is authoritative.
|
|
59
|
+
function readLaunchEnv(): ReadonlyMap<string, string> | undefined {
|
|
60
|
+
if (process.platform === "linux") {
|
|
61
|
+
try {
|
|
62
|
+
const values = new Map<string, string>();
|
|
63
|
+
for (const entry of fs.readFileSync("/proc/self/environ", "utf8").split("\0")) {
|
|
64
|
+
const separator = entry.indexOf("=");
|
|
65
|
+
if (separator > 0) values.set(entry.slice(0, separator), entry.slice(separator + 1));
|
|
66
|
+
}
|
|
67
|
+
return values;
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
if (!process.execArgv.includes("--no-env-file")) return undefined;
|
|
71
|
+
const values = new Map<string, string>();
|
|
72
|
+
for (const key in Bun.env) {
|
|
73
|
+
const value = Bun.env[key];
|
|
74
|
+
if (value !== undefined) values.set(key, value);
|
|
75
|
+
}
|
|
76
|
+
return values;
|
|
77
|
+
}
|
|
55
78
|
|
|
56
|
-
|
|
79
|
+
const launchEnvValues = readLaunchEnv();
|
|
80
|
+
const projectEnvNamesLoadedByOmp = new Set<string>();
|
|
81
|
+
|
|
82
|
+
function expandDotenvValues(values: Record<string, string>, env: Record<string, string>): Record<string, string> {
|
|
83
|
+
const expanded: Record<string, string> = {};
|
|
84
|
+
for (const key in values) {
|
|
85
|
+
expanded[key] = values[key].replace(
|
|
86
|
+
/(\\)?\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g,
|
|
87
|
+
(match, escaped: string | undefined, braced: string | undefined, bare: string | undefined) => {
|
|
88
|
+
if (escaped) return match.slice(1);
|
|
89
|
+
const name = braced ?? bare;
|
|
90
|
+
if (!name) return match;
|
|
91
|
+
return env[name] ?? expanded[name] ?? "";
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return expanded;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Filters process env for child shells without launch-cwd dotenv values. */
|
|
57
99
|
export function filterChildShellEnv(
|
|
58
100
|
env: Record<string, string | undefined>,
|
|
59
101
|
cwd: string = process.cwd(),
|
|
60
102
|
): Record<string, string> {
|
|
61
103
|
const result = filterProcessEnv(env);
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
104
|
+
const projectEnv = parseEnvFile(path.join(cwd, ".env"));
|
|
105
|
+
const nodeEnvName = `.env.${env.NODE_ENV || "development"}`;
|
|
106
|
+
const modeEnv = parseEnvFile(path.join(cwd, nodeEnvName));
|
|
107
|
+
const localEnv = parseEnvFile(path.join(cwd, ".env.local"));
|
|
108
|
+
const launchEnv = { ...projectEnv, ...modeEnv, ...localEnv };
|
|
109
|
+
const expandedLaunchEnv = {
|
|
110
|
+
...expandDotenvValues(projectEnv, result),
|
|
111
|
+
...expandDotenvValues(modeEnv, result),
|
|
112
|
+
...expandDotenvValues(localEnv, result),
|
|
113
|
+
};
|
|
114
|
+
for (const key in launchEnv) {
|
|
115
|
+
const launchValue = launchEnvValues?.get(key);
|
|
116
|
+
if (launchValue !== undefined) {
|
|
117
|
+
// Launcher-owned name: it keeps the launcher's own value. Bun overwrites
|
|
118
|
+
// an empty launcher value with the dotenv one, so restore the launcher
|
|
119
|
+
// value whenever what survived is exactly what the dotenv file defines.
|
|
120
|
+
if (
|
|
121
|
+
result[key] !== launchValue &&
|
|
122
|
+
(result[key] === launchEnv[key] || result[key] === expandedLaunchEnv[key])
|
|
123
|
+
) {
|
|
124
|
+
result[key] = launchValue;
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (launchEnvValues || projectEnvNamesLoadedByOmp.has(key)) {
|
|
129
|
+
// Strong provenance: the launch environment is known and this name is
|
|
130
|
+
// absent from it, or OMP itself injected the value — either way it came
|
|
131
|
+
// from a project dotenv file, not the parent shell.
|
|
132
|
+
delete result[key];
|
|
133
|
+
} else if (result[key] === launchEnv[key] || result[key] === expandedLaunchEnv[key]) {
|
|
134
|
+
// No launch-env snapshot (dotenv autoloaded without procfs): best-effort
|
|
135
|
+
// value match against the Bun-parsed dotenv.
|
|
136
|
+
delete result[key];
|
|
137
|
+
}
|
|
65
138
|
}
|
|
66
139
|
return result;
|
|
67
140
|
}
|
|
68
141
|
|
|
69
142
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* Returns
|
|
143
|
+
* Parse one dotenv line with Bun-compatible semantics: an optional `export`
|
|
144
|
+
* prefix, full-line `#` comments, inline `#` comments after whitespace on
|
|
145
|
+
* unquoted values, and single/double/backtick quoting (a `#` inside quotes
|
|
146
|
+
* stays literal). Returns undefined for blank lines, comments, and malformed
|
|
147
|
+
* names.
|
|
148
|
+
*/
|
|
149
|
+
function parseEnvLine(line: string): { key: string; value: string } | undefined {
|
|
150
|
+
const trimmed = line.trim();
|
|
151
|
+
if (!trimmed || trimmed.startsWith("#")) return undefined;
|
|
152
|
+
const eqIndex = trimmed.indexOf("=");
|
|
153
|
+
if (eqIndex === -1) return undefined;
|
|
154
|
+
let key = trimmed.slice(0, eqIndex).trim();
|
|
155
|
+
const exported = key.match(/^export[ \t]+(.*)$/);
|
|
156
|
+
if (exported) key = exported[1].trim();
|
|
157
|
+
if (!isValidEnvName(key)) return undefined;
|
|
158
|
+
const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, "");
|
|
159
|
+
const quote = raw[0];
|
|
160
|
+
if (quote === '"' || quote === "'" || quote === "`") {
|
|
161
|
+
let close = raw.indexOf(quote, 1);
|
|
162
|
+
while (close !== -1 && raw[close - 1] === "\\") close = raw.indexOf(quote, close + 1);
|
|
163
|
+
return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) };
|
|
164
|
+
}
|
|
165
|
+
const commentIndex = raw.search(/[ \t]#/);
|
|
166
|
+
return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Parses a .env file synchronously into key-value string pairs using
|
|
171
|
+
* {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid
|
|
172
|
+
* `OMP_` variables to their `PI_` aliases.
|
|
74
173
|
*/
|
|
75
174
|
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
76
175
|
const result: Record<string, string> = {};
|
|
77
176
|
try {
|
|
78
177
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
79
178
|
for (const line of content.split("\n")) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
83
|
-
|
|
84
|
-
const eqIndex = trimmed.indexOf("=");
|
|
85
|
-
if (eqIndex === -1) continue;
|
|
86
|
-
|
|
87
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
88
|
-
if (!isValidEnvName(key)) continue;
|
|
89
|
-
|
|
90
|
-
let value = trimmed.slice(eqIndex + 1).trim();
|
|
91
|
-
|
|
92
|
-
// Remove surrounding quotes (" or ')
|
|
93
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
94
|
-
value = value.slice(1, -1);
|
|
95
|
-
}
|
|
96
|
-
if (!isSafeEnvValue(value)) continue;
|
|
97
|
-
|
|
98
|
-
result[key] = value;
|
|
179
|
+
const parsed = parseEnvLine(line);
|
|
180
|
+
if (parsed && isSafeEnvValue(parsed.value)) result[parsed.key] = parsed.value;
|
|
99
181
|
}
|
|
100
182
|
} catch {
|
|
101
183
|
// File doesn't exist or can't be read - return empty result
|
|
@@ -128,6 +210,7 @@ for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
|
|
|
128
210
|
for (const key in file) {
|
|
129
211
|
if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
|
|
130
212
|
Bun.env[key] = file[key];
|
|
213
|
+
if (file === projectEnv) projectEnvNamesLoadedByOmp.add(key);
|
|
131
214
|
}
|
|
132
215
|
}
|
|
133
216
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from "./path";
|
|
|
19
19
|
export * from "./path-tree";
|
|
20
20
|
export * from "./peek-file";
|
|
21
21
|
export * as postmortem from "./postmortem";
|
|
22
|
+
export * from "./process-name";
|
|
22
23
|
export * as procmgr from "./procmgr";
|
|
23
24
|
export * as prompt from "./prompt";
|
|
24
25
|
export * as ptree from "./ptree";
|
package/src/logger.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/// <reference path="./winston-daily-rotate-file.d.ts" />
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Centralized logger for omp.
|
|
3
5
|
*
|
|
@@ -11,10 +13,13 @@
|
|
|
11
13
|
*/
|
|
12
14
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
13
15
|
import * as fs from "node:fs";
|
|
16
|
+
import * as os from "node:os";
|
|
14
17
|
import * as path from "node:path";
|
|
15
18
|
import { isPromise } from "node:util/types";
|
|
16
|
-
import
|
|
17
|
-
|
|
19
|
+
import type DailyRotateFile from "winston-daily-rotate-file";
|
|
20
|
+
// Import the implementation directly because the package index imports and mutates
|
|
21
|
+
// Winston. The exact workspace catalog pin protects this internal entrypoint.
|
|
22
|
+
import DailyRotateFileImplementation from "winston-daily-rotate-file/daily-rotate-file.js";
|
|
18
23
|
import { getLogsDir } from "./dirs";
|
|
19
24
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
20
25
|
/** Severity names accepted by the centralized logger. */
|
|
@@ -143,36 +148,68 @@ function jsonReplacer(_key: string, value: unknown): unknown {
|
|
|
143
148
|
return value;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
return JSON.stringify(entry, jsonReplacer);
|
|
166
|
-
}),
|
|
151
|
+
interface NormalizedLogInfo extends Record<string, unknown> {
|
|
152
|
+
level: LogLevel;
|
|
153
|
+
message: unknown;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function padTimestampPart(value: number, width = 2): string {
|
|
157
|
+
return String(value).padStart(width, "0");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function formatLocalTimestamp(date: Date): string {
|
|
161
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
162
|
+
const absoluteOffset = Math.abs(offsetMinutes);
|
|
163
|
+
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
|
|
164
|
+
return (
|
|
165
|
+
`${padTimestampPart(date.getFullYear(), 4)}-${padTimestampPart(date.getMonth() + 1)}-${padTimestampPart(date.getDate())}` +
|
|
166
|
+
`T${padTimestampPart(date.getHours())}:${padTimestampPart(date.getMinutes())}:${padTimestampPart(date.getSeconds())}` +
|
|
167
|
+
`.${padTimestampPart(date.getMilliseconds(), 3)}${offsetSign}${padTimestampPart(Math.floor(absoluteOffset / 60))}` +
|
|
168
|
+
`:${padTimestampPart(absoluteOffset % 60)}`
|
|
167
169
|
);
|
|
168
|
-
return logFormat;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
|
|
173
|
+
|
|
174
|
+
function normalizeLogInfo(
|
|
175
|
+
level: LogLevel,
|
|
176
|
+
message: string,
|
|
177
|
+
context: Record<string, unknown> | undefined,
|
|
178
|
+
): NormalizedLogInfo {
|
|
179
|
+
const metadata =
|
|
180
|
+
!FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
|
|
181
|
+
const info = Object.assign({}, metadata, { level, message }) as NormalizedLogInfo;
|
|
182
|
+
if (metadata?.message) info.message = `${message} ${metadata.message}`;
|
|
183
|
+
if (metadata?.stack) info.stack = metadata.stack;
|
|
184
|
+
if (metadata?.cause) info.cause = metadata.cause;
|
|
185
|
+
return info;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatLogInfo(info: NormalizedLogInfo): string {
|
|
189
|
+
const timestamp = formatLocalTimestamp(new Date());
|
|
190
|
+
info.timestamp = timestamp;
|
|
191
|
+
const entry: Record<string, unknown> = {
|
|
192
|
+
timestamp,
|
|
193
|
+
level: info.level,
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
message: info.message,
|
|
196
|
+
};
|
|
197
|
+
for (const [key, value] of Object.entries(info)) {
|
|
198
|
+
if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = value;
|
|
199
|
+
}
|
|
200
|
+
return JSON.stringify(entry, jsonReplacer) as string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type FileTransport = DailyRotateFile & {
|
|
204
|
+
log(info: Record<symbol, string>, callback: () => void): void;
|
|
205
|
+
close(): void;
|
|
206
|
+
};
|
|
207
|
+
|
|
171
208
|
/** Build a rotating file transport with process-local rotation and shared retention. */
|
|
172
|
-
function makeFileTransport(dir?: string):
|
|
209
|
+
function makeFileTransport(dir?: string): FileTransport {
|
|
173
210
|
const logsDir = ensureDir(dir ?? getLogsDir());
|
|
174
211
|
pruneStaleProcessLogs(logsDir);
|
|
175
|
-
return new
|
|
212
|
+
return new DailyRotateFileImplementation({
|
|
176
213
|
dirname: logsDir,
|
|
177
214
|
filename: `omp.%DATE%.${process.pid}.log`,
|
|
178
215
|
datePattern: "YYYY-MM-DD",
|
|
@@ -180,45 +217,48 @@ function makeFileTransport(dir?: string): winston.transport {
|
|
|
180
217
|
maxFiles: 5,
|
|
181
218
|
zippedArchive: false,
|
|
182
219
|
auditFile: path.join(logsDir, `.omp.${process.pid}-audit.json`),
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function makeConsoleTransport(): winston.transport {
|
|
187
|
-
return new winston.transports.Console({ format: getLogFormat() });
|
|
220
|
+
}) as FileTransport;
|
|
188
221
|
}
|
|
189
222
|
|
|
190
223
|
/**
|
|
191
|
-
* Desired transport configuration, applied when
|
|
224
|
+
* Desired transport configuration, applied when local logging is initialized.
|
|
192
225
|
* Default: file ON (TUI-safe), console OFF.
|
|
193
226
|
*/
|
|
194
227
|
let transportOpts: { console?: boolean; file?: boolean | string } = { file: true };
|
|
195
228
|
|
|
196
|
-
|
|
197
|
-
|
|
229
|
+
interface LocalTransports {
|
|
230
|
+
readonly file: FileTransport | undefined;
|
|
231
|
+
readonly console: boolean;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Local transports, constructed lazily on first log emission. */
|
|
235
|
+
let activeTransports: LocalTransports | undefined;
|
|
236
|
+
|
|
237
|
+
const TRANSPORT_MESSAGE = Symbol.for("message");
|
|
238
|
+
const onTransportLogged = (): void => {};
|
|
198
239
|
|
|
199
|
-
function buildTransports(opts: { console?: boolean; file?: boolean | string }):
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
240
|
+
function buildTransports(opts: { console?: boolean; file?: boolean | string }): LocalTransports {
|
|
241
|
+
return {
|
|
242
|
+
file: opts.file ? makeFileTransport(typeof opts.file === "string" ? opts.file : undefined) : undefined,
|
|
243
|
+
console: opts.console === true,
|
|
244
|
+
};
|
|
204
245
|
}
|
|
205
246
|
|
|
206
|
-
function
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
return winstonLogger;
|
|
247
|
+
function getLocalTransports(): LocalTransports {
|
|
248
|
+
activeTransports ??= buildTransports(transportOpts);
|
|
249
|
+
return activeTransports;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
|
|
253
|
+
const transports = getLocalTransports();
|
|
254
|
+
const info = normalizeLogInfo(level, message, context);
|
|
255
|
+
if (!transports.file && !transports.console) return;
|
|
256
|
+
|
|
257
|
+
// Winston applied the shared format before dispatch, then the same format a
|
|
258
|
+
// second time inside Console. Keep those evaluation and timestamp semantics.
|
|
259
|
+
const line = formatLogInfo(info);
|
|
260
|
+
if (transports.file) transports.file.log({ [TRANSPORT_MESSAGE]: line }, onTransportLogged);
|
|
261
|
+
if (transports.console) process.stdout.write(`${formatLogInfo(info)}${os.EOL}`);
|
|
222
262
|
}
|
|
223
263
|
|
|
224
264
|
/**
|
|
@@ -228,12 +268,11 @@ function getWinstonLogger(): winston.Logger {
|
|
|
228
268
|
*/
|
|
229
269
|
export function setTransports(opts: { console?: boolean; file?: boolean | string }): void {
|
|
230
270
|
transportOpts = opts;
|
|
231
|
-
if (!
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
winstonLogger.silent = transports.length === 0;
|
|
271
|
+
if (!activeTransports) return; // applied lazily when local logging is first initialized
|
|
272
|
+
const previousTransports = activeTransports;
|
|
273
|
+
activeTransports = { file: undefined, console: false };
|
|
274
|
+
previousTransports.file?.close();
|
|
275
|
+
activeTransports = buildTransports(opts);
|
|
237
276
|
}
|
|
238
277
|
|
|
239
278
|
/**
|
|
@@ -243,7 +282,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
|
|
|
243
282
|
*/
|
|
244
283
|
export function error(message: string, context?: Record<string, unknown>): void {
|
|
245
284
|
try {
|
|
246
|
-
|
|
285
|
+
emitLocally("error", message, context);
|
|
247
286
|
} catch {
|
|
248
287
|
// Silently ignore logging failures
|
|
249
288
|
}
|
|
@@ -257,7 +296,7 @@ export function error(message: string, context?: Record<string, unknown>): void
|
|
|
257
296
|
*/
|
|
258
297
|
export function warn(message: string, context?: Record<string, unknown>): void {
|
|
259
298
|
try {
|
|
260
|
-
|
|
299
|
+
emitLocally("warn", message, context);
|
|
261
300
|
} catch {
|
|
262
301
|
// Silently ignore logging failures
|
|
263
302
|
}
|
|
@@ -271,7 +310,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
|
|
|
271
310
|
*/
|
|
272
311
|
export function info(message: string, context?: Record<string, unknown>): void {
|
|
273
312
|
try {
|
|
274
|
-
|
|
313
|
+
emitLocally("info", message, context);
|
|
275
314
|
} catch {
|
|
276
315
|
// Silently ignore logging failures
|
|
277
316
|
}
|
|
@@ -285,7 +324,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
|
|
|
285
324
|
*/
|
|
286
325
|
export function debug(message: string, context?: Record<string, unknown>): void {
|
|
287
326
|
try {
|
|
288
|
-
|
|
327
|
+
emitLocally("debug", message, context);
|
|
289
328
|
} catch {
|
|
290
329
|
// Silently ignore logging failures
|
|
291
330
|
}
|
package/src/postmortem.ts
CHANGED
|
@@ -319,14 +319,20 @@ export function cleanup(): Promise<void> {
|
|
|
319
319
|
return runCleanup(Reason.MANUAL);
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
|
|
322
|
+
/** Controls how manual process shutdown handles terminal output. */
|
|
323
|
+
export interface QuitOptions {
|
|
324
|
+
/** Wait for buffered stdout before exiting; disable after the terminal has disconnected. */
|
|
325
|
+
drainStdout?: boolean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
|
|
323
329
|
await runCleanup(Reason.MANUAL);
|
|
324
330
|
|
|
325
331
|
if (!isMainThread) {
|
|
326
332
|
return; // Workers: cleanup done, let worker exit naturally
|
|
327
333
|
}
|
|
328
334
|
|
|
329
|
-
if (process.stdout.writableLength > 0) {
|
|
335
|
+
if (options.drainStdout !== false && process.stdout.writableLength > 0) {
|
|
330
336
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
331
337
|
process.stdout.once("drain", resolve);
|
|
332
338
|
await Promise.race([promise, Bun.sleep(5000)]);
|
|
@@ -343,9 +349,9 @@ async function runQuit(code: number, exitMode: "guarded" | "native"): Promise<vo
|
|
|
343
349
|
/**
|
|
344
350
|
* Runs all cleanup callbacks and exits through the current `process.exit`.
|
|
345
351
|
*
|
|
346
|
-
* In main thread: waits for stdout drain, then calls `process.exit()`.
|
|
352
|
+
* In main thread: waits for stdout drain unless disabled, then calls `process.exit()`.
|
|
347
353
|
* In workers: runs cleanup only (process.exit would kill entire process).
|
|
348
354
|
*/
|
|
349
|
-
export function quit(code: number = 0): Promise<void> {
|
|
350
|
-
return runQuit(code, "guarded");
|
|
355
|
+
export function quit(code: number = 0, options: QuitOptions = {}): Promise<void> {
|
|
356
|
+
return runQuit(code, "guarded", options);
|
|
351
357
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Set the OS-visible process name (`/proc/self/comm`) so `omp` shows up as
|
|
3
|
+
* `omp` — not `bun` — in `ps`, `pgrep`, `killall`, `top`, `htop`, and systemd.
|
|
4
|
+
*
|
|
5
|
+
* Bun's `process.title` setter only stores the value on the JS side; unlike
|
|
6
|
+
* Node/libuv it never calls `prctl(PR_SET_NAME)`, so the kernel's `comm` stays
|
|
7
|
+
* `bun` and process-name-based tooling can't target omp (and `pkill bun` becomes
|
|
8
|
+
* a footgun that kills every Bun process on the machine). We keep the
|
|
9
|
+
* `process.title` assignment (correct getter, future-proof if Bun ever fixes the
|
|
10
|
+
* setter) and additionally drive `prctl` via `bun:ffi` on Linux, mirroring the
|
|
11
|
+
* libc-FFI pattern in `ttyid.ts` / `stderr-guard.ts`.
|
|
12
|
+
*
|
|
13
|
+
* macOS has no clean userspace equivalent for the shebang-run path, and on
|
|
14
|
+
* Windows / compiled binaries the kernel derives the name from the exec'd file,
|
|
15
|
+
* so those paths already report correctly; there we only set `process.title`.
|
|
16
|
+
*/
|
|
17
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
|
|
20
|
+
/** `prctl(2)` option that sets the calling thread's `comm` name. */
|
|
21
|
+
const PR_SET_NAME = 15;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set both the JS `process.title` and — on Linux — the kernel `comm` name.
|
|
25
|
+
*
|
|
26
|
+
* Never throws: `bun:ffi` unavailability or a failed syscall degrades silently
|
|
27
|
+
* to the `process.title`-only behavior, so it is safe to call at startup.
|
|
28
|
+
*/
|
|
29
|
+
export function setProcessName(name: string): void {
|
|
30
|
+
try {
|
|
31
|
+
process.title = name;
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
if (os.platform() !== "linux") return;
|
|
35
|
+
|
|
36
|
+
// glibc first, then the generic soname for musl-style layouts (see stderr-guard.ts).
|
|
37
|
+
for (const soname of ["libc.so.6", "libc.so"]) {
|
|
38
|
+
try {
|
|
39
|
+
const libc = dlopen(soname, {
|
|
40
|
+
prctl: {
|
|
41
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.u64, FFIType.u64, FFIType.u64],
|
|
42
|
+
returns: FFIType.i32,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
// TASK_COMM_LEN is 16 (name + NUL); the kernel truncates the rest.
|
|
47
|
+
const buf = Buffer.from(`${name}\0`, "utf8");
|
|
48
|
+
libc.symbols.prctl(PR_SET_NAME, ptr(buf), 0n, 0n, 0n);
|
|
49
|
+
} finally {
|
|
50
|
+
libc.close();
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
} catch {
|
|
54
|
+
// bun:ffi unavailable or this soname missing; try the next candidate.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|