fix(updates): augment spawned PATH so npm/cargo resolve from Electron

The backend spawned by Electron inherits the desktop launcher's
environment, not a login shell: ~/.cargo/bin and the mise/asdf shim
dirs are missing, so 'Check for updates' failed with "'npm' was not
found" even though both tools exist in a terminal.

run() now sets PATH to the inherited value plus the well-known per-user
tool dirs (~/.cargo/bin, ~/.local/bin, mise + asdf shims,
/usr/local/bin), appended after the inherited entries so existing
resolutions keep priority. The NotFound hint now says what to do.

Verified live: update_check under env -i PATH=/usr/bin:/bin returns a
full report (npm advisories + cargo updates) where it previously errored.
Unit tests cover the pure PATH builder incl. missing-env fallback.
This commit is contained in:
Avi 2026-09-27 22:43:42 -05:00
commit fa59b63a17

View file

@ -72,6 +72,51 @@ fn frontend_dir() -> PathBuf {
source_dir().join("frontend") source_dir().join("frontend")
} }
/// PATH used for package-manager commands.
///
/// When the backend is spawned by Electron its environment is the desktop
/// launcher's, NOT a login shell: `~/.cargo/bin` (cargo) and the node
/// version-manager bins/shims (npm) are missing, so the update commands
/// failed with "'npm' was not found" even though both exist in a terminal.
/// Augment the inherited PATH with the well-known per-user tool locations,
/// appended after the inherited entries: where a tool is already resolvable
/// on the inherited PATH that version wins, and entries that do not exist
/// are simply ignored by exec.
fn augmented_path() -> std::ffi::OsString {
let inherited = std::env::var_os("PATH");
let home = std::env::var_os("HOME");
build_augmented_path(inherited.as_deref(), home.as_deref())
}
/// Pure form of [`augmented_path`], testable without mutating the process
/// environment.
fn build_augmented_path(
inherited: Option<&std::ffi::OsStr>,
home: Option<&std::ffi::OsStr>,
) -> std::ffi::OsString {
let inherited_parts: Vec<PathBuf> = inherited
.map(std::env::split_paths)
.map(|p| p.collect())
.unwrap_or_default();
let home = home.map(PathBuf::from);
let extra: Vec<PathBuf> = [
".cargo/bin",
".local/bin",
".local/share/mise/shims",
".asdf/shims",
"/usr/local/bin",
]
.into_iter()
.filter_map(|rel| match rel.strip_prefix('/') {
// A leading slash marks an absolute system entry: use it verbatim.
Some(_) => Some(PathBuf::from(rel)),
None => home.as_ref().map(|h| h.join(rel)),
})
.collect();
std::env::join_paths(inherited_parts.into_iter().chain(extra))
.unwrap_or_else(|_| std::ffi::OsString::from("/usr/local/bin:/usr/bin:/bin"))
}
/// Verify the app is running from its source checkout before shelling out. /// Verify the app is running from its source checkout before shelling out.
fn require_source_checkout() -> Result<(), AppError> { fn require_source_checkout() -> Result<(), AppError> {
let manifest = source_dir().join("Cargo.toml"); let manifest = source_dir().join("Cargo.toml");
@ -88,13 +133,22 @@ fn require_source_checkout() -> Result<(), AppError> {
/// Run a command with a timeout, capturing stdout/stderr separately. /// Run a command with a timeout, capturing stdout/stderr separately.
async fn run(dir: &Path, program: &str, args: &[&str]) -> Result<std::process::Output, AppError> { async fn run(dir: &Path, program: &str, args: &[&str]) -> Result<std::process::Output, AppError> {
let mut command = Command::new(program); let mut command = Command::new(program);
command.current_dir(dir).args(args).kill_on_drop(true); command
.current_dir(dir)
.args(args)
// See augmented_path(): Electron-spawned backends inherit a desktop
// PATH without the per-user tool dirs, and npm/cargo "don't exist".
.env("PATH", augmented_path())
.kill_on_drop(true);
let future = command.output(); let future = command.output();
match tokio::time::timeout(COMMAND_TIMEOUT, future).await { match tokio::time::timeout(COMMAND_TIMEOUT, future).await {
Ok(Ok(output)) => Ok(output), Ok(Ok(output)) => Ok(output),
Ok(Err(err)) => { Ok(Err(err)) => {
let hint = if err.kind() == std::io::ErrorKind::NotFound { let hint = if err.kind() == std::io::ErrorKind::NotFound {
format!("'{program}' was not found on this computer.") format!(
"'{program}' was not found on this computer. \
Install it (or add it to the app's PATH) and try again."
)
} else { } else {
format!("Could not run '{program}': {err}") format!("Could not run '{program}': {err}")
}; };
@ -355,6 +409,29 @@ pub async fn apply() -> Result<UpdateApplyReport, AppError> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn augmented_path_appends_tool_dirs_after_inherited() {
let joined = build_augmented_path(
Some(std::ffi::OsStr::new("/usr/bin:/bin")),
Some(std::ffi::OsStr::new("/home/tester")),
);
let joined = joined.to_string_lossy().into_owned();
// Inherited entries keep priority.
assert!(joined.starts_with("/usr/bin:/bin:"));
// The dirs an Electron-spawned process is missing are now present.
assert!(joined.contains("/home/tester/.cargo/bin"));
assert!(joined.contains("/home/tester/.local/share/mise/shims"));
assert!(joined.contains("/usr/local/bin"));
}
#[test]
fn augmented_path_survives_missing_env() {
// No PATH and no HOME: still a usable absolute system path.
let joined = build_augmented_path(None, None);
let joined = joined.to_string_lossy().into_owned();
assert!(joined.contains("/usr/local/bin"));
}
#[test] #[test]
fn parses_npm_outdated_entries() { fn parses_npm_outdated_entries() {
let json = r#"{ let json = r#"{