@oh-my-pi/pi-utils 17.1.8 → 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.
@@ -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>;
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.8",
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.8",
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/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,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
+ }