@oh-my-pi/pi-utils 17.1.7 → 17.1.8

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";
@@ -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.1.8",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
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.1.8",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
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";
@@ -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
+ }