os-deps: ask the owner's shell where a binary is, instead of guessing directories
`Bun.which` reads the PATH this process started with, and under PM2 that is minimal — a release binary fetched during an install was reported missing seconds later by the same install. The fix was 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 elsewhere, and every per-user installer adds a directory nobody listed. The iris plugin duly wrote its own copy of the function with ~/.cargo/bin bolted on, which then disagreed with this one: the manifest probe refused a rustup-only machine that the sidecar could build on perfectly well. There is nothing 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 it on PATH — that is what those files are for. So ask it: `$SHELL -lc 'command -v <binary>'`, with the shell taken from passwd rather than process.env.SHELL, which under PM2 is whatever started the daemon. sessions.mjs already draws that distinction for terminals; this is the same fact applied to dependency probing. A login shell because ~/.cargo/env and friends are sourced from profile files an interactive-only shell may skip. `command -v` because it is a POSIX builtin whatever the shell is. Two-second timeout so an rc file that waits for input cannot hang an install. Bun.which stays as the fallback, with ~/.local/bin prepended there and only there, for an account with no usable shell. Exported as locateBinary so a plugin's sidecar asks the same question the install did, rather than writing a second answer. Verified on this machine: the login shell resolves ~/.local/bin and ~/.opencode/bin, neither of which is on this process's PATH, and finds a binary Bun.which cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user