diff --git a/src/servers/plugins/os-deps.ts b/src/servers/plugins/os-deps.ts index aafd87b9..beeb1654 100644 --- a/src/servers/plugins/os-deps.ts +++ b/src/servers/plugins/os-deps.ts @@ -1,5 +1,5 @@ -import { chmodSync, mkdirSync, renameSync } from 'node:fs'; -import { homedir } from 'node:os'; +import { chmodSync, existsSync, mkdirSync, renameSync } from 'node:fs'; +import { homedir, userInfo } from 'node:os'; import { join } from 'node:path'; import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest'; @@ -105,24 +105,70 @@ function identifies(dep: OsDependency, path: string): boolean { } /** - * Where to look for a binary. + * Find a binary the way the OWNER would, by asking their login shell. * - * `~/.local/bin` FIRST, and explicitly, for two reasons that are both bugs otherwise: + * ── Why not `Bun.which`, and why not a list of directories ── * - * `Bun.which()` with no options reads the PATH this PROCESS started with. Under PM2 that is a minimal - * environment which frequently does not include `~/.local/bin` at all — so a release binary fetched - * during an install would be reported missing seconds later by `stillMissing`, and the install would - * fail having just succeeded. + * `Bun.which()` reads the PATH THIS PROCESS started with, and under PM2 that is a minimal environment. + * It does not contain `~/.local/bin`, so a release binary fetched during an install was reported missing + * seconds later by `stillMissing` and the install failed having just succeeded. * - * It is also where `installRelease` puts things, so preferring it means the copy the platform installed - * wins over a namesake the distribution supplied. `identify` catches the wrong one either way; this - * decides which is found first. + * The fix used to be prepending `~/.local/bin` here. That is guessing at somebody's environment from + * inside a crippled one, and guessing does not converge: rustup puts cargo in `~/.cargo/bin`, nvm puts + * node somewhere else again, and every tool that installs per-user adds a directory nobody listed. The + * iris plugin duly wrote its OWN copy of this function with `~/.cargo/bin` added, which then disagreed + * with this one — the manifest probe refused a rustup-only machine that the sidecar could build on. + * + * There is no need to guess. The platform runs as the owner, plugins are installed by the owner, and the + * owner has a shell whose rc files already put all of that on PATH — that is what those files are for. + * So ask it. `sessions.mjs` already draws this distinction for terminals, noting that PM2's `$SHELL` is + * "whatever PM2 was started with"; this is the same fact applied to dependency probing. + * + * A LOGIN shell (`-l`), because `~/.cargo/env` and friends are sourced from profile files that an + * interactive-but-not-login shell may skip. `command -v` rather than `which`, because it is a POSIX + * builtin that exists whatever shell this is. */ -const searchPath = (): string => `${join(homedir(), '.local', 'bin')}:${process.env.PATH ?? ''}`; +function findViaLoginShell(binary: string): string | null { + // The owner's shell from passwd, NOT `process.env.SHELL` — under PM2 that is whatever started the + // daemon, which is the same class of mistake this function exists to stop making. + const shell = userInfo().shell || process.env.SHELL || '/bin/sh'; + try { + const out = Bun.spawnSync([shell, '-lc', `command -v ${binary}`], { + stdout: 'pipe', + stderr: 'pipe', + // An rc file that waits for input, or a slow one, must not hang an install. Two seconds is far more + // than sourcing profiles costs and far less than anyone would sit through. + timeout: 2_000, + }); + const path = new TextDecoder().decode(out.stdout).trim().split('\n')[0]; + return path && existsSync(path) ? path : null; + } catch { + return null; + } +} -/** Probe one dependency against the live PATH. */ +/** + * Where a binary is, or null. + * + * The login shell answers first because it knows the owner's real PATH. `Bun.which` is kept as a + * fallback for the case the shell cannot: a non-interactive account, a shell that is not POSIX, an rc + * file that fails. `~/.local/bin` stays prepended THERE and only there — it is where `installRelease` + * puts things, so a fallback that could not see it would report a just-installed binary as missing. + * + * EXPORTED so a plugin's sidecar asks the same question the install did. iris had written its own copy + * with `~/.cargo/bin` bolted on, which disagreed with this one and refused a machine it could build on. + * Two implementations of "where is this binary" is one too many. + */ +export function locateBinary(binary: string): string | null { + return ( + findViaLoginShell(binary) ?? + Bun.which(binary, { PATH: `${join(homedir(), '.local', 'bin')}:${process.env.PATH ?? ''}` }) + ); +} + +/** Probe one dependency the way the owner's shell would see it. */ function probe(dep: OsDependency, manager: PackageManager | null): DependencyStatus { - const path = Bun.which(dep.binary, { PATH: searchPath() }); + const path = locateBinary(dep.binary); const genuine = path ? identifies(dep, path) : false; return { binary: dep.binary,