@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 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
@@ -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 `.env.local` values. */
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 and extracts key-value string pairs.
24
- * Ignores lines that are empty or start with '#'. Trims whitespace.
25
- * Allows values to be quoted with single or double quotes.
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
  /**
@@ -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.1.7",
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.1.7",
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": "^5.0.0"
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
- /** Filters process env for child shells without launch-cwd `.env.local` values. */
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 launchLocalEnv = parseEnvFile(path.join(cwd, ".env.local"));
63
- for (const key in launchLocalEnv) {
64
- if (result[key] === launchLocalEnv[key]) delete result[key];
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
- * Parses a .env file synchronously and extracts key-value string pairs.
71
- * Ignores lines that are empty or start with '#'. Trims whitespace.
72
- * Allows values to be quoted with single or double quotes.
73
- * Returns an object of key-value pairs.
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 trimmed = line.trim();
81
- // Skip comments and blank lines
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 winston from "winston";
17
- import DailyRotateFile from "winston-daily-rotate-file";
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
- /** Custom format that includes pid and flattens metadata; built on first use. */
147
- let logFormat: winston.Logform.Format | undefined;
148
-
149
- function getLogFormat(): winston.Logform.Format {
150
- logFormat ??= winston.format.combine(
151
- winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
152
- winston.format.printf(({ timestamp, level, message, ...meta }) => {
153
- const entry: Record<string, unknown> = {
154
- timestamp,
155
- level,
156
- pid: process.pid,
157
- message,
158
- };
159
- // Flatten metadata into entry
160
- for (const [key, value] of Object.entries(meta)) {
161
- if (key !== "level" && key !== "timestamp" && key !== "message") {
162
- entry[key] = value;
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): winston.transport {
209
+ function makeFileTransport(dir?: string): FileTransport {
173
210
  const logsDir = ensureDir(dir ?? getLogsDir());
174
211
  pruneStaleProcessLogs(logsDir);
175
- return new DailyRotateFile({
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 the winston logger is built.
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
- /** The winston logger instance, created lazily on first log emission. */
197
- let winstonLogger: winston.Logger | undefined;
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 }): winston.transport[] {
200
- const transports: winston.transport[] = [];
201
- if (opts.file) transports.push(makeFileTransport(typeof opts.file === "string" ? opts.file : undefined));
202
- if (opts.console) transports.push(makeConsoleTransport());
203
- return transports;
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 getWinstonLogger(): winston.Logger {
207
- if (!winstonLogger) {
208
- const transports = buildTransports(transportOpts);
209
- winstonLogger = winston.createLogger({
210
- level: "debug",
211
- format: getLogFormat(),
212
- transports,
213
- // A transport-less winston logger console.warns "Attempt to write logs
214
- // with no transports" on every emit; mark it silent instead so disabling
215
- // all transports is a clean no-op.
216
- silent: transports.length === 0,
217
- // Don't exit on error - logging failures shouldn't crash the app
218
- exitOnError: false,
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 (!winstonLogger) return; // applied lazily when the logger is first built
232
- winstonLogger.clear();
233
- const transports = buildTransports(opts);
234
- for (const transport of transports) winstonLogger.add(transport);
235
- // Keep the logger silent when nothing is attached so winston doesn't warn on emit.
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
- getWinstonLogger().error(message, context);
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
- getWinstonLogger().warn(message, context);
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
- getWinstonLogger().info(message, context);
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
- getWinstonLogger().debug(message, context);
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
- async function runQuit(code: number, exitMode: "guarded" | "native"): Promise<void> {
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
+ }
@@ -0,0 +1,6 @@
1
+ declare module "winston-daily-rotate-file/daily-rotate-file.js" {
2
+ import type DailyRotateFile from "winston-daily-rotate-file";
3
+
4
+ const DailyRotateFileImplementation: typeof DailyRotateFile;
5
+ export default DailyRotateFileImplementation;
6
+ }