mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
On macOS, apps launched from Finder/Dock inherit a minimal environment (PATH is just /usr/bin:/bin:/usr/sbin:/sbin). This caused two problems: 1. Agent binaries like codex were not detected (findExecutable failed) 2. Terminals spawned by Paseo had no access to user-installed tools (node, bun, direnv — all "command not found") Fix: at Electron startup, spawn the user's login shell and capture its full environment via JSON.stringify(process.env) using UUID markers. This is the same battle-tested approach VS Code uses. The resolved environment is merged into process.env before the daemon starts, so all child processes — agents, terminals, git operations — inherit the correct environment automatically. This replaces the previous approach of invoking resolveShellEnv() (via the shell-env npm package) at multiple scattered call sites. Now there is a single source of truth: process.env is enriched once at startup. Changes: - New: packages/desktop/src/login-shell-env.ts (VS Code approach) - Removed: resolveShellEnv(), shell-env dependency, $SHELL -lic probes - Simplified: applyProviderEnv() and findExecutable() now trust process.env
160 lines
4.5 KiB
TypeScript
160 lines
4.5 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { platform } from "node:os";
|
|
import path from "node:path";
|
|
export interface FindExecutableDependencies {
|
|
execFileSync: typeof execFileSync;
|
|
existsSync: typeof existsSync;
|
|
platform: typeof platform;
|
|
}
|
|
|
|
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
|
|
try {
|
|
const output = deps.execFileSync(
|
|
"powershell",
|
|
[
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
[
|
|
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")',
|
|
'$user = [Environment]::GetEnvironmentVariable("Path", "User")',
|
|
"if ($machine) { Write-Output $machine }",
|
|
"if ($user) { Write-Output $user }",
|
|
].join("; "),
|
|
],
|
|
{ encoding: "utf8" },
|
|
);
|
|
return output
|
|
.split(/\r?\n/)
|
|
.flatMap((line) => line.split(";"))
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function resolveExecutableFromWhichOutput(
|
|
name: string,
|
|
output: string,
|
|
source: "which",
|
|
): string | null {
|
|
const lines = output
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
const candidate = lines.at(-1);
|
|
|
|
if (!candidate) {
|
|
return null;
|
|
}
|
|
|
|
if (!path.isAbsolute(candidate)) {
|
|
console.warn(
|
|
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
/**
|
|
* On Unix we use plain `which` — the daemon's process.env.PATH is enriched
|
|
* with the login shell environment at Electron desktop startup.
|
|
*
|
|
* On Windows we augment the daemon PATH with machine/user registry PATH values
|
|
* and return the first `where.exe` match. Launch-time execution decides whether
|
|
* the resolved path needs `cmd.exe` semantics (for example npm shims under
|
|
* nvm4w such as `C:\nvm4w\nodejs\codex`).
|
|
*/
|
|
export function findExecutable(
|
|
name: string,
|
|
dependencies?: FindExecutableDependencies,
|
|
): string | null {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const deps: FindExecutableDependencies = {
|
|
execFileSync,
|
|
existsSync,
|
|
platform,
|
|
...dependencies,
|
|
};
|
|
|
|
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
return deps.existsSync(trimmed) ? trimmed : null;
|
|
}
|
|
|
|
if (deps.platform() === "win32") {
|
|
try {
|
|
const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? "";
|
|
const resolvedPath = [
|
|
...inheritedPath.split(";"),
|
|
...resolveWindowsPathEntries(deps),
|
|
]
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0)
|
|
.filter((entry, index, entries) => entries.indexOf(entry) === index)
|
|
.join(";");
|
|
const env = {
|
|
...process.env,
|
|
PATH: resolvedPath,
|
|
Path: resolvedPath,
|
|
};
|
|
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
|
|
return (
|
|
out
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find((line) => line.length > 0) ?? null
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
return resolveExecutableFromWhichOutput(
|
|
trimmed,
|
|
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
|
"which",
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function isCommandAvailable(command: string): boolean {
|
|
return findExecutable(command) !== null;
|
|
}
|
|
|
|
/**
|
|
* When spawning with `shell: true` on Windows, the command is passed to
|
|
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
|
|
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
|
|
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
|
|
*/
|
|
export function quoteWindowsCommand(command: string): string {
|
|
if (process.platform !== "win32") return command;
|
|
if (!command.includes(" ")) return command;
|
|
if (command.startsWith('"') && command.endsWith('"')) return command;
|
|
return `"${command}"`;
|
|
}
|
|
|
|
/**
|
|
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
|
|
* Any argument containing spaces must be quoted or it will be split before the
|
|
* child process sees it.
|
|
*/
|
|
export function quoteWindowsArgument(argument: string): string {
|
|
if (process.platform !== "win32") return argument;
|
|
if (!argument.includes(" ")) return argument;
|
|
if (argument.startsWith('"') && argument.endsWith('"')) return argument;
|
|
return `"${argument}"`;
|
|
}
|
|
|