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

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;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
3
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
4
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
5
+ */
6
+ export declare function isValidEnvName(name: string): boolean;
7
+ /**
8
+ * The only names that are genuinely unsafe to forward to a native `execve`
9
+ * spawn: empty, containing `=` (would corrupt the `KEY=VALUE` framing) or
10
+ * NUL (terminates the C string mid-entry). Windows ships standard variables
11
+ * whose names contain parentheses (e.g. `ProgramFiles(x86)`, `CommonProgramFiles(x86)`)
12
+ * — those MUST survive the scrub so downstream resolvers (Git Bash discovery
13
+ * in `procmgr.ts`, etc.) can still read them.
14
+ */
15
+ export declare function isSafeEnvName(name: string): boolean;
16
+ export declare function isSafeEnvValue(value: string): boolean;
17
+ export declare function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string>;
18
+ /**
19
+ * Parses a .env file synchronously and extracts key-value string pairs.
20
+ * Ignores lines that are empty or start with '#'. Trims whitespace.
21
+ * Allows values to be quoted with single or double quotes.
22
+ * Returns an object of key-value pairs.
23
+ */
24
+ export declare function parseEnvFile(filePath: string): Record<string, string>;
1
25
  /**
2
26
  * Intentional re-export of Bun.env.
3
27
  *
@@ -58,7 +58,7 @@ export declare function fetchWithRetry(url: string | URL | ((attempt: number) =>
58
58
  * Inspect an arbitrary error value (or its `cause` chain, up to depth 2) for an
59
59
  * HTTP status code. Reads `status`, `statusCode`, and `response.status` fields,
60
60
  * coerces string values, and falls back to scanning the error message for
61
- * common patterns like `error (429)` or `HTTP 503`.
61
+ * common patterns like `Error: 401`, `error (429)`, or `HTTP 503`.
62
62
  */
63
63
  export declare function extractHttpStatusFromError(error: unknown): number | undefined;
64
64
  /**
@@ -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.4",
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.4",
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/env.ts CHANGED
@@ -3,13 +3,50 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { getAgentDir, getConfigRootDir } from "./dirs";
5
5
 
6
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+
8
+ /**
9
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
10
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
11
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
12
+ */
13
+ export function isValidEnvName(name: string): boolean {
14
+ return ENV_NAME_RE.test(name);
15
+ }
16
+
17
+ /**
18
+ * The only names that are genuinely unsafe to forward to a native `execve`
19
+ * spawn: empty, containing `=` (would corrupt the `KEY=VALUE` framing) or
20
+ * NUL (terminates the C string mid-entry). Windows ships standard variables
21
+ * whose names contain parentheses (e.g. `ProgramFiles(x86)`, `CommonProgramFiles(x86)`)
22
+ * — those MUST survive the scrub so downstream resolvers (Git Bash discovery
23
+ * in `procmgr.ts`, etc.) can still read them.
24
+ */
25
+ export function isSafeEnvName(name: string): boolean {
26
+ return name.length > 0 && !name.includes("=") && !name.includes("\0");
27
+ }
28
+
29
+ export function isSafeEnvValue(value: string): boolean {
30
+ return !value.includes("\0");
31
+ }
32
+
33
+ export function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string> {
34
+ const result: Record<string, string> = {};
35
+ for (const key in env) {
36
+ const value = env[key];
37
+ if (!isSafeEnvName(key) || value === undefined || !isSafeEnvValue(value)) continue;
38
+ result[key] = value;
39
+ }
40
+ return result;
41
+ }
42
+
6
43
  /**
7
44
  * Parses a .env file synchronously and extracts key-value string pairs.
8
45
  * Ignores lines that are empty or start with '#'. Trims whitespace.
9
46
  * Allows values to be quoted with single or double quotes.
10
47
  * Returns an object of key-value pairs.
11
48
  */
12
- function parseEnvFile(filePath: string): Record<string, string> {
49
+ export function parseEnvFile(filePath: string): Record<string, string> {
13
50
  const result: Record<string, string> = {};
14
51
  try {
15
52
  const content = fs.readFileSync(filePath, "utf-8");
@@ -22,12 +59,15 @@ function parseEnvFile(filePath: string): Record<string, string> {
22
59
  if (eqIndex === -1) continue;
23
60
 
24
61
  const key = trimmed.slice(0, eqIndex).trim();
62
+ if (!isValidEnvName(key)) continue;
63
+
25
64
  let value = trimmed.slice(eqIndex + 1).trim();
26
65
 
27
66
  // Remove surrounding quotes (" or ')
28
67
  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
29
68
  value = value.slice(1, -1);
30
69
  }
70
+ if (!isSafeEnvValue(value)) continue;
31
71
 
32
72
  result[key] = value;
33
73
  }
@@ -51,10 +91,17 @@ const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
51
91
  const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
52
92
  const projectEnv = parseEnvFile(path.join(process.cwd(), ".env"));
53
93
 
94
+ for (const key of Object.keys(Bun.env)) {
95
+ const value = Bun.env[key];
96
+ if (!isSafeEnvName(key) || value === undefined || !isSafeEnvValue(value)) {
97
+ delete Bun.env[key];
98
+ }
99
+ }
100
+
54
101
  for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
55
- for (const [key, value] of Object.entries(file)) {
102
+ for (const key in file) {
56
103
  if (!Bun.env[key]) {
57
- Bun.env[key] = value;
104
+ Bun.env[key] = file[key];
58
105
  }
59
106
  }
60
107
  }
@@ -198,7 +198,7 @@ function resolveDefaultDelay(
198
198
  * Inspect an arbitrary error value (or its `cause` chain, up to depth 2) for an
199
199
  * HTTP status code. Reads `status`, `statusCode`, and `response.status` fields,
200
200
  * coerces string values, and falls back to scanning the error message for
201
- * common patterns like `error (429)` or `HTTP 503`.
201
+ * common patterns like `Error: 401`, `error (429)`, or `HTTP 503`.
202
202
  */
203
203
  export function extractHttpStatusFromError(error: unknown): number | undefined {
204
204
  return extractHttpStatusFromErrorInternal(error, 0);
@@ -236,6 +236,7 @@ function extractHttpStatusFromErrorInternal(error: unknown, depth: number): numb
236
236
  }
237
237
 
238
238
  const STATUS_MESSAGE_PATTERNS = [
239
+ /\berror\s*[:=]\s*(\d{3})\b/i,
239
240
  /error\s*\((\d{3})\)/i,
240
241
  /status\s*[:=]?\s*(\d{3})/i,
241
242
  /\bhttp\s*(\d{3})\b/i,
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.
package/src/procmgr.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
4
4
  import type { Subprocess } from "bun";
5
- import { $env } from "./env";
5
+ import { $env, filterProcessEnv } from "./env";
6
6
  import { $which } from "./which";
7
7
 
8
8
  export interface ShellConfig {
@@ -45,7 +45,7 @@ function isExecutable(path: string): boolean {
45
45
  function buildSpawnEnv(shell: string): Record<string, string> {
46
46
  const noCI = $env.PI_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
47
47
  return {
48
- ...Bun.env,
48
+ ...filterProcessEnv(Bun.env),
49
49
  SHELL: shell,
50
50
  GIT_EDITOR: "true",
51
51
  GPG_TTY: "not a tty",
@@ -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
+ }