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;
}