Spaces:
Runtime error
Runtime error
File size: 3,473 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | // Login-shell PATH resolution (#3321).
//
// macOS GUI / Electron apps do NOT inherit the user's interactive shell PATH: a packaged
// app launched from Finder/Dock gets the truncated GUI PATH (/usr/bin:/bin:/usr/sbin:/sbin),
// missing Homebrew (/opt/homebrew/bin), nvm/volta shims, ~/.local/bin, etc. So any CLI
// detection (`which`, `command -v`) or CLI spawn run from that process can't find tools
// that ARE on the user's shell PATH — every CLI shows up as "not installed". We recover
// the real PATH by asking the user's login shell for it, then merge it into the lookup env.
//
// The pure helpers below are unit-tested with an injected shell runner; only the actual
// `$SHELL -ilc` spawn is platform-dependent.
import { execFileSync } from "node:child_process";
import path from "node:path";
/** Merge an extra PATH string into a base PATH: de-duped, base entries kept first. */
export function mergeShellPath(
basePath: string,
extraPath: string,
delimiter: string = path.delimiter
): string {
const seen = new Set<string>();
const out: string[] = [];
for (const entry of [...basePath.split(delimiter), ...extraPath.split(delimiter)]) {
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
out.push(trimmed);
}
return out.join(delimiter);
}
/** Extract the value of the `PATH=` line from `env`-style shell output. */
export function parseShellPathOutput(output: string): string | null {
if (!output) return null;
for (const line of output.split("\n")) {
if (line.startsWith("PATH=")) {
return line.slice("PATH=".length).trim() || null;
}
}
return null;
}
export interface LoginShellPathOptions {
platform?: NodeJS.Platform;
shell?: string;
/** Injectable shell runner (returns the raw stdout); defaults to a safe execFileSync. */
runShell?: (shell: string) => string;
}
/**
* Query the user's login shell for its PATH. Returns null on non-darwin platforms (where
* the launching terminal already exports the full PATH), on failure, or when no PATH can
* be parsed.
*
* Hard Rule #13-safe: the shell binary comes from `$SHELL` (OS-provided, not untrusted
* input) and is validated against a strict charset; the command is a literal argv array
* (`["-ilc", "command -p env"]`) with NO string interpolation.
*/
export function getLoginShellPath(opts: LoginShellPathOptions = {}): string | null {
const platform = opts.platform ?? process.platform;
if (platform !== "darwin") return null;
const shell = opts.shell || process.env.SHELL || "/bin/zsh";
if (!/^[\w./-]+$/.test(shell)) return null;
const run =
opts.runShell ||
((sh: string): string =>
execFileSync(sh, ["-ilc", "command -p env"], {
encoding: "utf8",
timeout: 3000,
stdio: ["ignore", "pipe", "ignore"],
}));
try {
return parseShellPathOutput(run(shell));
} catch {
return null;
}
}
// The `$SHELL -ilc` spawn costs ~100-500ms, so compute it once per process.
let cached: string | null | undefined;
/** Cached {@link getLoginShellPath} — computed once, reused for every detection/spawn. */
export function getCachedLoginShellPath(): string | null {
if (cached === undefined) cached = getLoginShellPath();
return cached;
}
/** Test-only: clear the module-level cache so a test can re-stub the runner. */
export function __resetLoginShellPathCacheForTesting(value?: string | null): void {
cached = value;
}
|