Skip to content

Commit 615b373

Browse files
authored
fix: ensure correct PATH when launched from Finder/Spotlight (#383)
1 parent c839a95 commit 615b373

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333

3434
## Code Style
3535

36+
- Prefer writing our own solution over adding external packages when the fix is simple
37+
- Keep functions focused with single responsibility
3638
- Biome for linting and formatting (not ESLint/Prettier)
3739
- 2-space indentation, double quotes
3840
- No `console.*` in source - use logger instead (logger files exempt)

apps/array/src/main/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
declare const __BUILD_COMMIT__: string | undefined;
22
declare const __BUILD_DATE__: string | undefined;
33

4+
import { fixPath } from "./lib/fixPath.js";
5+
6+
// Call fixPath early to ensure PATH is correct for any child processes
7+
fixPath();
8+
49
import "reflect-metadata";
510
import dns from "node:dns";
611

apps/array/src/main/lib/fixPath.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* When launched from Finder/Spotlight, Electron apps inherit a minimal PATH
3+
* (/usr/bin:/bin:/usr/sbin:/sbin) instead of the user's shell PATH which
4+
* includes /opt/homebrew/bin, ~/.local/bin, etc.
5+
*
6+
* This reads the PATH from the user's default shell (in interactive login mode)
7+
* and applies it to process.env.PATH so child processes have access to
8+
* user-installed binaries.
9+
*/
10+
11+
import { execSync } from "node:child_process";
12+
import { userInfo } from "node:os";
13+
14+
const DELIMITER = "_SHELL_ENV_DELIMITER_";
15+
16+
const FALLBACK_PATHS = [
17+
"./node_modules/.bin",
18+
"/opt/homebrew/bin",
19+
"/opt/homebrew/sbin",
20+
"/usr/local/bin",
21+
];
22+
23+
// Regex to strip ANSI escape codes from shell output
24+
const ANSI_REGEX =
25+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional for ANSI stripping
26+
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
27+
28+
function stripAnsi(str: string): string {
29+
return str.replace(ANSI_REGEX, "");
30+
}
31+
32+
function detectDefaultShell(): string {
33+
if (process.platform === "win32") {
34+
return process.env.COMSPEC || "cmd.exe";
35+
}
36+
37+
try {
38+
const { shell } = userInfo();
39+
if (shell) {
40+
return shell;
41+
}
42+
} catch {
43+
// userInfo() can throw on some systems
44+
}
45+
46+
if (process.platform === "darwin") {
47+
return process.env.SHELL || "/bin/zsh";
48+
}
49+
50+
return process.env.SHELL || "/bin/sh";
51+
}
52+
53+
function executeShell(shell: string): string | undefined {
54+
const command = `echo -n "${DELIMITER}"; env; echo -n "${DELIMITER}"; exit`;
55+
56+
try {
57+
return execSync(`${shell} -ilc '${command}'`, {
58+
encoding: "utf-8",
59+
timeout: 5000,
60+
stdio: ["ignore", "pipe", "ignore"],
61+
env: {
62+
...process.env,
63+
// Disable Oh My Zsh auto-update which can block
64+
DISABLE_AUTO_UPDATE: "true",
65+
},
66+
});
67+
} catch {
68+
return undefined;
69+
}
70+
}
71+
72+
function parseEnvOutput(stdout: string): Record<string, string> | undefined {
73+
const parts = stdout.split(DELIMITER);
74+
if (parts.length < 2) {
75+
return undefined;
76+
}
77+
78+
const envOutput = stripAnsi(parts[1]);
79+
const result: Record<string, string> = {};
80+
81+
for (const line of envOutput.split("\n")) {
82+
if (!line) continue;
83+
const eqIndex = line.indexOf("=");
84+
if (eqIndex > 0) {
85+
const key = line.slice(0, eqIndex);
86+
const value = line.slice(eqIndex + 1);
87+
result[key] = value;
88+
}
89+
}
90+
91+
return result;
92+
}
93+
94+
function getShellPath(shell: string): string | undefined {
95+
const stdout = executeShell(shell);
96+
if (!stdout) {
97+
return undefined;
98+
}
99+
100+
const env = parseEnvOutput(stdout);
101+
return env?.PATH;
102+
}
103+
104+
function buildFallbackPath(): string {
105+
return [...FALLBACK_PATHS, process.env.PATH].filter(Boolean).join(":");
106+
}
107+
108+
export function fixPath(): void {
109+
if (process.platform === "win32") {
110+
return;
111+
}
112+
113+
const shell = detectDefaultShell();
114+
const shellPath = getShellPath(shell);
115+
116+
if (shellPath) {
117+
process.env.PATH = stripAnsi(shellPath);
118+
} else {
119+
process.env.PATH = buildFallbackPath();
120+
}
121+
}

0 commit comments

Comments
 (0)