@oh-my-pi/pi-utils 15.1.2 → 15.1.3

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.
@@ -137,3 +137,15 @@ export declare function getProjectPluginOverridesPath(cwd?: string): string;
137
137
  export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string): string;
138
138
  /** Get the SSH config file path. */
139
139
  export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
140
+ /**
141
+ * Persistent per-install UUID stored at `~/.omp/install-id`.
142
+ *
143
+ * Generated lazily on first call and persisted with `O_CREAT|O_EXCL` so
144
+ * concurrent first-call races don't clobber each other (loser re-reads the
145
+ * winner's id). Survives independently of agent state: deleting
146
+ * `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
147
+ * pushes (and similar telemetry) keys on this id.
148
+ */
149
+ export declare function getInstallId(): string;
150
+ /** Test-only: clear cached install id. Never call from production code. */
151
+ export declare function __resetInstallIdCacheForTests(): void;
@@ -19,6 +19,7 @@ export * as procmgr from "./procmgr";
19
19
  export * as prompt from "./prompt";
20
20
  export * as ptree from "./ptree";
21
21
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
22
+ export * from "./sanitize-text";
22
23
  export * from "./snowflake";
23
24
  export * from "./stream";
24
25
  export * from "./tab-spacing";
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Replace the active log transports. Pass `console: true, file: false` for
3
+ * long-running services (the auth broker, etc.) that want their structured
4
+ * logs piped into a process supervisor instead of the rotating file.
5
+ */
6
+ export declare function setTransports(opts: {
7
+ console?: boolean;
8
+ file?: boolean | string;
9
+ }): void;
1
10
  /**
2
11
  * Log an error message.
3
12
  * @param message - The message to log.
@@ -10,6 +19,12 @@ export declare function error(message: string, context?: Record<string, unknown>
10
19
  * @param context - The context to log.
11
20
  */
12
21
  export declare function warn(message: string, context?: Record<string, unknown>): void;
22
+ /**
23
+ * Log an informational message.
24
+ * @param message - The message to log.
25
+ * @param context - The context to log.
26
+ */
27
+ export declare function info(message: string, context?: Record<string, unknown>): void;
13
28
  /**
14
29
  * Log a debug message.
15
30
  * @param message - The message to log.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Strip ANSI escape sequences, remove control characters / lone surrogates,
3
+ * and normalize line endings.
4
+ *
5
+ * Bun-native implementation of the former native `sanitizeText` (see
6
+ * `crates/pi-natives/src/text.rs::sanitize_text`). JavaScript strings are
7
+ * already UTF-16 code-unit arrays. `toWellFormed()` handles the uncommon
8
+ * malformed path; when it changes the input, replacement characters are
9
+ * dropped and the normalized result goes through the well-formed sanitizer.
10
+ *
11
+ * Fast path: well-formed input with no controls or ANSI returns the original
12
+ * string after the control probe.
13
+ */
14
+ export declare function sanitizeText(text: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "15.1.2",
4
+ "version": "15.1.3",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,14 +31,14 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
+ "@oh-my-pi/pi-natives": "15.1.3",
34
35
  "beautiful-mermaid": "^1.1.3",
35
36
  "handlebars": "^4.7.9",
36
37
  "winston": "^3.19.0",
37
38
  "winston-daily-rotate-file": "^5.0.0"
38
39
  },
39
40
  "devDependencies": {
40
- "@types/bun": "^1.3.14",
41
- "@oh-my-pi/pi-natives": "15.1.2"
41
+ "@types/bun": "^1.3.14"
42
42
  },
43
43
  "engines": {
44
44
  "bun": ">=1.3.14"
package/src/dirs.ts CHANGED
@@ -477,3 +477,76 @@ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getPro
477
477
  }
478
478
  return path.join(getProjectAgentDir(cwd), "ssh.json");
479
479
  }
480
+
481
+ // =============================================================================
482
+ // Install identity
483
+ // =============================================================================
484
+
485
+ let cachedInstallId: string | null = null;
486
+
487
+ const INSTALL_ID_FILE = "install-id";
488
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
489
+
490
+ /**
491
+ * Persistent per-install UUID stored at `~/.omp/install-id`.
492
+ *
493
+ * Generated lazily on first call and persisted with `O_CREAT|O_EXCL` so
494
+ * concurrent first-call races don't clobber each other (loser re-reads the
495
+ * winner's id). Survives independently of agent state: deleting
496
+ * `~/.omp/agent/` does not regenerate it. Server-side dedup for grievance
497
+ * pushes (and similar telemetry) keys on this id.
498
+ */
499
+ export function getInstallId(): string {
500
+ if (cachedInstallId) return cachedInstallId;
501
+ const filePath = path.join(getConfigRootDir(), INSTALL_ID_FILE);
502
+
503
+ let observedInvalid = false;
504
+ try {
505
+ const existing = fs.readFileSync(filePath, "utf8").trim();
506
+ if (UUID_RE.test(existing)) {
507
+ cachedInstallId = existing;
508
+ return existing;
509
+ }
510
+ // File present but unparseable — fall through and overwrite below.
511
+ observedInvalid = existing.length > 0;
512
+ } catch {}
513
+
514
+ const next = crypto.randomUUID();
515
+ try {
516
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
517
+ // If we already saw garbage in the file, unlink first so O_EXCL doesn't
518
+ // trip on it. Ignored if the unlink races against another writer.
519
+ if (observedInvalid) {
520
+ try {
521
+ fs.unlinkSync(filePath);
522
+ } catch {}
523
+ }
524
+ const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
525
+ try {
526
+ fs.writeSync(fd, `${next}\n`);
527
+ } finally {
528
+ fs.closeSync(fd);
529
+ }
530
+ } catch (err) {
531
+ // Lost the create race — re-read whatever the winner wrote.
532
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
533
+ try {
534
+ const existing = fs.readFileSync(filePath, "utf8").trim();
535
+ if (UUID_RE.test(existing)) {
536
+ cachedInstallId = existing;
537
+ return existing;
538
+ }
539
+ } catch {}
540
+ }
541
+ // Any other failure: keep the generated id in-memory so the rest of
542
+ // this process has a stable value; future processes will retry.
543
+ }
544
+
545
+ cachedInstallId = next;
546
+ return next;
547
+ }
548
+
549
+ /** Test-only: clear cached install id. Never call from production code. */
550
+ export function __resetInstallIdCacheForTests(): void {
551
+ cachedInstallId = null;
552
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export * as procmgr from "./procmgr";
19
19
  export * as prompt from "./prompt";
20
20
  export * as ptree from "./ptree";
21
21
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
22
+ export * from "./sanitize-text";
22
23
  export * from "./snowflake";
23
24
  export * from "./stream";
24
25
  export * from "./tab-spacing";
package/src/logger.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Centralized file logger for omp.
2
+ * Centralized logger for omp.
3
3
  *
4
- * Logs to ~/.omp/logs/ with size-based rotation, supporting concurrent omp instances.
5
- * Each log entry includes process.pid for traceability.
4
+ * Default: rotating `~/.omp/logs/omp.<DATE>.log`, no console output (writing
5
+ * to stdout/stderr would corrupt the TUI). Long-running headless services
6
+ * (the auth broker, etc.) call {@link setTransports} to swap in a console
7
+ * transport so a process supervisor (pm2, journald, k8s) captures the logs.
8
+ *
9
+ * Each entry includes `process.pid` so concurrent omp instances stay
10
+ * traceable.
6
11
  */
7
12
  import { AsyncLocalStorage } from "node:async_hooks";
8
13
  import * as fs from "node:fs";
@@ -10,13 +15,12 @@ import winston from "winston";
10
15
  import DailyRotateFile from "winston-daily-rotate-file";
11
16
  import { getLogsDir } from "./dirs";
12
17
 
13
- /** Ensure logs directory exists */
14
- function ensureLogsDir(): string {
15
- const logsDir = getLogsDir();
16
- if (!fs.existsSync(logsDir)) {
17
- fs.mkdirSync(logsDir, { recursive: true });
18
+ /** Ensure a logs directory exists; return the resolved path. */
19
+ function ensureDir(dir: string): string {
20
+ if (!fs.existsSync(dir)) {
21
+ fs.mkdirSync(dir, { recursive: true });
18
22
  }
19
- return logsDir;
23
+ return dir;
20
24
  }
21
25
 
22
26
  /** Custom format that includes pid and flattens metadata */
@@ -39,25 +43,44 @@ const logFormat = winston.format.combine(
39
43
  }),
40
44
  );
41
45
 
42
- /** Size-based rotating file transport */
43
- const fileTransport = new DailyRotateFile({
44
- dirname: ensureLogsDir(),
45
- filename: "omp.%DATE%.log",
46
- datePattern: "YYYY-MM-DD",
47
- maxSize: "10m",
48
- maxFiles: 5,
49
- zippedArchive: true,
50
- });
46
+ /** Build a rotating file transport, materializing the target directory lazily. */
47
+ function makeFileTransport(dir?: string): winston.transport {
48
+ return new DailyRotateFile({
49
+ dirname: ensureDir(dir ?? getLogsDir()),
50
+ filename: "omp.%DATE%.log",
51
+ datePattern: "YYYY-MM-DD",
52
+ maxSize: "10m",
53
+ maxFiles: 5,
54
+ zippedArchive: true,
55
+ });
56
+ }
57
+
58
+ function makeConsoleTransport(): winston.transport {
59
+ return new winston.transports.Console({ format: logFormat });
60
+ }
51
61
 
52
- /** The winston logger instance */
62
+ /** The winston logger instance. Default: file ON (TUI-safe), console OFF. */
53
63
  const winstonLogger = winston.createLogger({
54
64
  level: "debug",
55
65
  format: logFormat,
56
- transports: [fileTransport],
66
+ transports: [makeFileTransport()],
57
67
  // Don't exit on error - logging failures shouldn't crash the app
58
68
  exitOnError: false,
59
69
  });
60
70
 
71
+ /**
72
+ * Replace the active log transports. Pass `console: true, file: false` for
73
+ * long-running services (the auth broker, etc.) that want their structured
74
+ * logs piped into a process supervisor instead of the rotating file.
75
+ */
76
+ export function setTransports(opts: { console?: boolean; file?: boolean | string }): void {
77
+ winstonLogger.clear();
78
+ if (opts.file) {
79
+ winstonLogger.add(makeFileTransport(typeof opts.file === "string" ? opts.file : undefined));
80
+ }
81
+ if (opts.console) winstonLogger.add(makeConsoleTransport());
82
+ }
83
+
61
84
  /**
62
85
  * Log an error message.
63
86
  * @param message - The message to log.
@@ -84,6 +107,19 @@ export function warn(message: string, context?: Record<string, unknown>): void {
84
107
  }
85
108
  }
86
109
 
110
+ /**
111
+ * Log an informational message.
112
+ * @param message - The message to log.
113
+ * @param context - The context to log.
114
+ */
115
+ export function info(message: string, context?: Record<string, unknown>): void {
116
+ try {
117
+ winstonLogger.info(message, context);
118
+ } catch {
119
+ // Silently ignore logging failures
120
+ }
121
+ }
122
+
87
123
  /**
88
124
  * Log a debug message.
89
125
  * @param message - The message to log.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Strip ANSI escape sequences, remove control characters / lone surrogates,
3
+ * and normalize line endings.
4
+ *
5
+ * Bun-native implementation of the former native `sanitizeText` (see
6
+ * `crates/pi-natives/src/text.rs::sanitize_text`). JavaScript strings are
7
+ * already UTF-16 code-unit arrays. `toWellFormed()` handles the uncommon
8
+ * malformed path; when it changes the input, replacement characters are
9
+ * dropped and the normalized result goes through the well-formed sanitizer.
10
+ *
11
+ * Fast path: well-formed input with no controls or ANSI returns the original
12
+ * string after the control probe.
13
+ */
14
+
15
+ const ESC_CHAR = "\x1b";
16
+
17
+ // Well-formed strings only need control/ANSI detection: C0 (excl. \t \n),
18
+ // CR, DEL, and C1. ESC (0x1B) is in \x0B-\x1F.
19
+ const CONTROL_RE = /[\x00-\x08\x0B-\x1F\x7F-\x9F]/g;
20
+
21
+ const REPLACEMENT_CHAR = "\ufffd";
22
+
23
+ export function sanitizeText(text: string): string {
24
+ const wellFormed = text.toWellFormed();
25
+ if (wellFormed !== text) {
26
+ return sanitizeWellFormedText(wellFormed.replaceAll(REPLACEMENT_CHAR, ""));
27
+ }
28
+ return sanitizeWellFormedText(text);
29
+ }
30
+
31
+ function sanitizeWellFormedText(text: string): string {
32
+ CONTROL_RE.lastIndex = 0;
33
+ if (CONTROL_RE.exec(text) === null) return text;
34
+
35
+ const stripped = text.indexOf(ESC_CHAR) === -1 ? text : Bun.stripANSI(text);
36
+ CONTROL_RE.lastIndex = 0;
37
+ return stripped.replace(CONTROL_RE, "");
38
+ }