From fa59b63a1725dc6c629e76eaa8ac4faf127d5d13 Mon Sep 17 00:00:00 2001 From: Avi Date: Sun, 27 Sep 2026 22:43:42 -0500 Subject: [PATCH] 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. --- src/updates.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/updates.rs b/src/updates.rs index ef75d9b..2217a15 100644 --- a/src/updates.rs +++ b/src/updates.rs @@ -72,6 +72,51 @@ fn frontend_dir() -> PathBuf { 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 = inherited + .map(std::env::split_paths) + .map(|p| p.collect()) + .unwrap_or_default(); + let home = home.map(PathBuf::from); + let extra: Vec = [ + ".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. fn require_source_checkout() -> Result<(), AppError> { 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. async fn run(dir: &Path, program: &str, args: &[&str]) -> Result { 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(); match tokio::time::timeout(COMMAND_TIMEOUT, future).await { Ok(Ok(output)) => Ok(output), Ok(Err(err)) => { 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 { format!("Could not run '{program}': {err}") }; @@ -355,6 +409,29 @@ pub async fn apply() -> Result { mod tests { 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] fn parses_npm_outdated_entries() { let json = r#"{