From 4e0aa500c995d6a7070289e1fb5fdc3601b4ae36 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 5 Aug 2026 09:16:43 -0700 Subject: [PATCH 1/5] Add cross-channel install guard for cargo, winget, and Homebrew Prompt to uninstall an existing numan from another package manager before installing via a different channel; declining cancels the install. Cargo install runs the guard automatically via build.rs; winget and Homebrew use wrapper scripts. Doctor warns when multiple channels are detected. Co-authored-by: Cursor --- AGENTS.md | 2 + Cargo.lock | 5 + Cargo.toml | 11 +- README.md | 6 +- build.rs | 7 + docs/PACKAGING.md | 18 +- docs/numan-doctor.md | 1 + install-guard/Cargo.toml | 11 + install-guard/src/lib.rs | 434 ++++++++++++++++++++++++++++++++++++ install-guard/src/main.rs | 18 ++ packaging/winget/README.md | 8 + scripts/install-cargo.ps1 | 33 +++ scripts/install-homebrew.sh | 16 ++ scripts/install-winget.ps1 | 38 ++++ src/cmd/doctor.rs | 55 +++++ 15 files changed, 655 insertions(+), 8 deletions(-) create mode 100644 build.rs create mode 100644 install-guard/Cargo.toml create mode 100644 install-guard/src/lib.rs create mode 100644 install-guard/src/main.rs create mode 100644 scripts/install-cargo.ps1 create mode 100644 scripts/install-homebrew.sh create mode 100644 scripts/install-winget.ps1 diff --git a/AGENTS.md b/AGENTS.md index b966df38..a78b5ab3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ CI runs `cargo test`, `cargo clippy -- -D warnings`, `cargo fmt --check`, and a ## Project Structure ``` +build.rs — cargo-install guard (cross-channel install detection via install-guard) +install-guard/ — shared install-channel detection + interactive uninstall prompt src/ main.rs — CLI entry point (clap-based) config.rs — Config load/save, root resolution diff --git a/Cargo.lock b/Cargo.lock index 2d3b1823..230b48a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,6 +1213,7 @@ dependencies = [ "hex", "indicatif", "libc", + "numan-install-guard", "rand_core", "reqwest", "semver", @@ -1229,6 +1230,10 @@ dependencies = [ "zip", ] +[[package]] +name = "numan-install-guard" +version = "0.1.0" + [[package]] name = "number_prefix" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 33a384c3..ac13ced2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,13 +17,14 @@ exclude = [ "tests/fixtures/**", "Phase*.md", ".tmp*", + "install-guard/**", ] -[[bin]] -name = "numan" -path = "src/main.rs" +[build-dependencies] +numan-install-guard = { path = "install-guard" } [dependencies] +numan-install-guard = { path = "install-guard" } # CLI clap = { version = "4", features = ["derive"] } clap_complete = "4" @@ -76,6 +77,10 @@ uuid = { version = "1", features = ["v7", "v4"] } # Git (for source builds and registry sync) git2 = "0.21" +[[bin]] +name = "numan" +path = "src/main.rs" + # stdio redirect for doctor --json repair passes (Unix only) [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/README.md b/README.md index 60665d18..33767e7c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ brew tap tonythethompson/numan brew install numan ``` -Uses the public [`homebrew-numan`](https://github.com/tonythethompson/homebrew-numan) tap. Formula digests update automatically after each GitHub Release (see [docs/PACKAGING.md](docs/PACKAGING.md)). +Uses the public [`homebrew-numan`](https://github.com/tonythethompson/homebrew-numan) tap. Formula digests update automatically after each GitHub Release (see [docs/PACKAGING.md](docs/PACKAGING.md)). If you already have numan from cargo or winget, use `scripts/install-homebrew.sh` so the installer prompts to remove the other copy first. ### winget (Windows) @@ -135,6 +135,8 @@ Uses the public [`homebrew-numan`](https://github.com/tonythethompson/homebrew-n winget install tonythethompson.numan ``` +If you already have numan from **cargo** or **Homebrew**, use `scripts/install-winget.ps1` (winget) or `cargo install` (automatic guard) so the installer prompts to remove the other copy first. + See [packaging/winget/README.md](packaging/winget/README.md) and [docs/PACKAGING.md](docs/PACKAGING.md). ### crates.io @@ -143,7 +145,7 @@ See [packaging/winget/README.md](packaging/winget/README.md) and [docs/PACKAGING cargo install numan-cli ``` -Requires [Rust](https://rustup.rs/) (stable). The installed binary is named `numan`. +Requires [Rust](https://rustup.rs/) (stable). The installed binary is named `numan`. During `cargo install`, numan checks for winget/Homebrew/release copies and prompts to uninstall them first; decline cancels the install. **Requirements:** a [Nushell](https://www.nushell.sh/) binary on `PATH` for `numan init`, `numan activate`, and related commands. diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..4f519dd9 --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + if std::env::var("CARGO_INSTALL_ROOT").is_ok() + && numan_install_guard::run_cargo_install_guard() != std::process::ExitCode::SUCCESS + { + std::process::exit(1); + } +} diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index a4dc5be1..0ddeb5ad 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -37,10 +37,22 @@ python3 scripts/render_homebrew_formula.py --version X.Y.Z --sha256sums SHA256SU | GitHub Release | Download archive from [Releases](https://github.com/tonythethompson/numan/releases) | | crates.io | `cargo install numan-cli` | | From git | `cargo install --git https://github.com/tonythethompson/numan` | -| Homebrew (tap) | `brew tap tonythethompson/numan && brew install numan` | -| winget (community) | `winget install tonythethompson.numan` | +| Homebrew tap | `brew tap tonythethompson/numan && brew install numan` (use `scripts/install-homebrew.sh` when switching from cargo/winget) | +| winget (community) | `winget install tonythethompson.numan` (use `scripts/install-winget.ps1` when switching from cargo/Homebrew) | + +## Install channel guard + +Cross-channel installs (cargo vs winget vs Homebrew) prompt to uninstall the existing copy first; declining cancels the install. + +| Channel | Guard behavior | +|---------|----------------| +| `cargo install numan-cli` | Automatic via `build.rs` during install | +| winget | `powershell -File scripts/install-winget.ps1` | +| Homebrew tap | `bash scripts/install-homebrew.sh` | +| CI / automation | Set `NUMAN_SKIP_INSTALL_GUARD=1` to bypass | + +`numan doctor` warns when multiple channels are detected (`install.multiple_channels`). -## Archive layout Release archives extract to `numan--/` containing the `numan` (or `numan.exe`) binary. Homebrew and winget installers assume this layout. diff --git a/docs/numan-doctor.md b/docs/numan-doctor.md index 1d53ddcc..5413b8e5 100644 --- a/docs/numan-doctor.md +++ b/docs/numan-doctor.md @@ -129,6 +129,7 @@ Checks run in order below. Implementation should call existing validators (`NuPa |----|----------|-----------| | `nu.binary.missing_on_path` | `error` | Nu not on PATH and not under `$NUMAN_ROOT/tools/nushell/` → fix: `numan setup nu` | | `nu.binary.found_off_path` | `warn` | Nu exists in a known install root (e.g. `~/.cargo/bin`, `%LOCALAPPDATA%\Programs\nushell`) but not on PATH → fix: `numan setup nu use ` | +| `install.multiple_channels` | `warn` | More than one `numan` binary from different install channels (cargo, winget, homebrew, release archive) → fix: uninstall the other channel(s) before reinstalling | | `nu.path.version` | `info` | PATH-only Nu version (`PATH Nu: 0.114.1`), `PATH Nu: not found`, or `PATH Nu: found at '' but version probe failed ()` when the binary exists but `--version` fails. Does not treat managed Nu as PATH. Report-only (no automatic repair). | | `nu.managed.version` | `info` | Managed binary under `$NUMAN_ROOT/tools/nushell/` with version, `Managed Nu: not installed`, or `Managed Nu: present at '' but version probe failed ()` when the binary exists but `--version` fails. Report-only (no automatic repair). | | `nu.active_version.invalid` | `error` | `nu_state/active-version.json` is present but unreadable/invalid JSON. Lookup would otherwise soft-miss the marker and fall back to PATH. **auto:** copy raw bytes to `active-version.json.corrupt` (best-effort, recoverable `binary_path`), then clear via `clear_active_version` so resolution recovers cleanly. | diff --git a/install-guard/Cargo.toml b/install-guard/Cargo.toml new file mode 100644 index 00000000..7ece0472 --- /dev/null +++ b/install-guard/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "numan-install-guard" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "numan-install-guard" +path = "src/main.rs" + +[dependencies] diff --git a/install-guard/src/lib.rs b/install-guard/src/lib.rs new file mode 100644 index 00000000..1686b31d --- /dev/null +++ b/install-guard/src/lib.rs @@ -0,0 +1,434 @@ +//! Detect Numan binaries installed via different package managers and gate +//! cross-channel installs behind an interactive uninstall prompt. + +use std::collections::HashSet; +use std::ffi::OsStr; +use std::io::{IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; + +/// How a `numan` binary on disk was likely installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum InstallChannel { + Cargo, + Winget, + Homebrew, + ReleaseArchive, + Unknown, +} + +impl InstallChannel { + pub fn label(self) -> &'static str { + match self { + InstallChannel::Cargo => "cargo", + InstallChannel::Winget => "winget", + InstallChannel::Homebrew => "homebrew", + InstallChannel::ReleaseArchive => "release archive", + InstallChannel::Unknown => "unknown", + } + } + + pub fn uninstall_hint(self) -> Option<&'static str> { + match self { + InstallChannel::Cargo => Some("cargo uninstall numan-cli"), + InstallChannel::Winget => Some("winget uninstall tonythethompson.numan"), + InstallChannel::Homebrew => Some("brew uninstall numan"), + InstallChannel::ReleaseArchive | InstallChannel::Unknown => None, + } + } +} + +/// One discovered `numan` binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredInstall { + pub path: PathBuf, + pub channel: InstallChannel, +} + +/// Classify a binary path by install channel heuristics. +pub fn classify_binary_path(path: &Path) -> InstallChannel { + let normalized = path.to_string_lossy().replace('\\', "/").to_ascii_lowercase(); + + if normalized.contains("/.cargo/bin/numan") + || normalized.ends_with("/.cargo/bin/numan.exe") + { + return InstallChannel::Cargo; + } + + if normalized.contains("/microsoft/winget/") + || normalized.contains("/winget/packages/") + || (normalized.contains("winget") && normalized.contains("numan")) + { + return InstallChannel::Winget; + } + + if normalized.contains("/cellar/numan/") + || normalized.contains("/homebrew/numan/") + || normalized.contains("/linuxbrew/numan/") + { + return InstallChannel::Homebrew; + } + + if normalized.contains("/numan-") + && normalized + .split('/') + .any(|part| part.starts_with("numan-") && part.len() > "numan-".len()) + { + return InstallChannel::ReleaseArchive; + } + + InstallChannel::Unknown +} + +/// Discover `numan` binaries on PATH and in known package-manager locations. +pub fn discover_installations() -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + + for dir in path_directories() { + push_if_numan(&dir, &mut seen, &mut out); + } + + for candidate in known_install_candidates() { + if candidate.is_file() { + push_install(candidate, &mut seen, &mut out); + } + } + + out.sort_by(|a, b| a.path.cmp(&b.path)); + out +} + +fn push_if_numan(dir: &Path, seen: &mut HashSet, out: &mut Vec) { + let candidates = [ + dir.join("numan"), + dir.join("numan.exe"), + ]; + for candidate in candidates { + if candidate.is_file() { + push_install(candidate, seen, out); + } + } +} + +fn push_install(path: PathBuf, seen: &mut HashSet, out: &mut Vec) { + let canonical = path.canonicalize().unwrap_or(path); + if seen.insert(canonical.clone()) { + let channel = classify_binary_path(&canonical); + out.push(DiscoveredInstall { + path: canonical, + channel, + }); + } +} + +fn path_directories() -> Vec { + let key = if cfg!(windows) { "Path" } else { "PATH" }; + std::env::var_os(key) + .map(|value| std::env::split_paths(&value).collect()) + .unwrap_or_default() +} + +fn known_install_candidates() -> Vec { + let mut candidates = Vec::new(); + + if let Some(home) = home_dir() { + candidates.push(home.join(".cargo").join("bin").join(if cfg!(windows) { + "numan.exe" + } else { + "numan" + })); + } + + if cfg!(windows) { + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + let winget_packages = PathBuf::from(local).join("Microsoft").join("WinGet").join("Packages"); + candidates.extend(find_numan_under(&winget_packages, 4)); + } + } + + if cfg!(target_os = "macos") { + candidates.push(PathBuf::from("/opt/homebrew/Cellar/numan")); + candidates.push(PathBuf::from("/usr/local/Cellar/numan")); + candidates.extend(find_numan_under(&PathBuf::from("/opt/homebrew/Cellar/numan"), 3)); + candidates.extend(find_numan_under(&PathBuf::from("/usr/local/Cellar/numan"), 3)); + } + + if cfg!(target_os = "linux") { + if let Some(home) = home_dir() { + let linuxbrew = home.join(".linuxbrew/Cellar/numan"); + candidates.extend(find_numan_under(&linuxbrew, 3)); + } + candidates.extend(find_numan_under(&PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/numan"), 3)); + } + + candidates +} + +fn find_numan_under(root: &Path, max_depth: usize) -> Vec { + if !root.exists() { + return Vec::new(); + } + let mut found = Vec::new(); + walk_for_numan(root, 0, max_depth, &mut found); + found +} + +fn walk_for_numan(dir: &Path, depth: usize, max_depth: usize, found: &mut Vec) { + if depth > max_depth { + return; + } + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk_for_numan(&path, depth + 1, max_depth, found); + } else if is_numan_binary(&path) { + found.push(path); + } + } +} + +fn is_numan_binary(path: &Path) -> bool { + match path.file_name().and_then(OsStr::to_str) { + Some("numan") | Some("numan.exe") => true, + _ => false, + } +} + +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +/// Conflicting installs for a new install via `channel`, excluding `exclude_paths`. +pub fn conflicting_installs( + channel: InstallChannel, + exclude_paths: &[PathBuf], +) -> Vec { + let excluded: HashSet = exclude_paths + .iter() + .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())) + .collect(); + + discover_installations() + .into_iter() + .filter(|install| !excluded.contains(&install.path)) + .filter(|install| install.channel != channel) + .collect() +} + +/// Run the cargo-install guard. Returns process exit code. +pub fn run_cargo_install_guard() -> ExitCode { + let install_root = std::env::var("CARGO_INSTALL_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|_| default_cargo_install_root()); + let target = install_root.join("bin").join(if cfg!(windows) { + "numan.exe" + } else { + "numan" + }); + + run_install_guard(InstallChannel::Cargo, &[target]) +} + +/// Run the winget-install guard. Returns process exit code. +pub fn run_winget_install_guard() -> ExitCode { + run_install_guard(InstallChannel::Winget, &[]) +} + +/// Run the homebrew-install guard. Returns process exit code. +pub fn run_homebrew_install_guard() -> ExitCode { + run_install_guard(InstallChannel::Homebrew, &[]) +} + +fn default_cargo_install_root() -> PathBuf { + if let Some(home) = home_dir() { + return home.join(".cargo"); + } + PathBuf::from(".cargo") +} + +fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> ExitCode { + if should_skip_guard() { + return ExitCode::SUCCESS; + } + + let conflicts = conflicting_installs(channel, exclude_paths); + if conflicts.is_empty() { + return ExitCode::SUCCESS; + } + + let is_tty = std::io::stdin().is_terminal(); + print_conflict_banner(channel, &conflicts); + + if !is_tty { + eprintln!( + "Refusing {} install in non-interactive session while another channel is installed.", + channel.label() + ); + eprintln!("Remove the existing install first, or set NUMAN_SKIP_INSTALL_GUARD=1 to bypass."); + return ExitCode::from(1); + } + + let channel_labels: Vec<&str> = conflicts + .iter() + .map(|c| c.channel.label()) + .collect::>() + .into_iter() + .collect(); + print!( + "Uninstall the existing {} install(s) before continuing? [y/N] ", + channel_labels.join(", ") + ); + let _ = std::io::stdout().flush(); + + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + eprintln!("Failed to read confirmation; cancelling install."); + return ExitCode::from(1); + } + + if !input.trim().eq_ignore_ascii_case("y") { + eprintln!("Install cancelled."); + return ExitCode::from(1); + } + + for conflict in &conflicts { + if !uninstall_channel(conflict.channel) { + eprintln!( + "Could not uninstall {} install at {}.", + conflict.channel.label(), + conflict.path.display() + ); + if let Some(hint) = conflict.channel.uninstall_hint() { + eprintln!("Run manually: {hint}"); + } + return ExitCode::from(1); + } + } + + let remaining = conflicting_installs(channel, exclude_paths); + if !remaining.is_empty() { + eprintln!("Existing install still detected after uninstall; cancelling install."); + for install in remaining { + eprintln!(" {} ({})", install.path.display(), install.channel.label()); + } + return ExitCode::from(1); + } + + ExitCode::SUCCESS +} + +fn should_skip_guard() -> bool { + std::env::var("NUMAN_SKIP_INSTALL_GUARD") + .map(|v| v == "1") + .unwrap_or(false) + || std::env::var("CI").ok().as_deref() == Some("true") + || std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") +} + +fn print_conflict_banner(channel: InstallChannel, conflicts: &[DiscoveredInstall]) { + eprintln!(); + eprintln!( + "Another numan install was detected while installing via {}.", + channel.label() + ); + for conflict in conflicts { + eprintln!( + " {} via {}", + conflict.path.display(), + conflict.channel.label() + ); + if let Some(hint) = conflict.channel.uninstall_hint() { + eprintln!(" uninstall: {hint}"); + } + } + eprintln!(); +} + +fn uninstall_channel(channel: InstallChannel) -> bool { + match channel { + InstallChannel::Cargo => run_command("cargo", &["uninstall", "numan-cli"]), + InstallChannel::Winget => run_command( + "winget", + &[ + "uninstall", + "--id", + "tonythethompson.numan", + "--exact", + "--accept-source-agreements", + ], + ), + InstallChannel::Homebrew => run_command("brew", &["uninstall", "numan"]), + InstallChannel::ReleaseArchive | InstallChannel::Unknown => false, + } +} + +fn run_command(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_cargo_path() { + let path = Path::new("/home/user/.cargo/bin/numan"); + assert_eq!(classify_binary_path(path), InstallChannel::Cargo); + } + + #[test] + fn classify_winget_path() { + let path = Path::new( + "C:/Users/x/AppData/Local/Microsoft/WinGet/Packages/foo/numan.exe", + ); + assert_eq!(classify_binary_path(path), InstallChannel::Winget); + } + + #[test] + fn classify_homebrew_path() { + let path = Path::new("/opt/homebrew/Cellar/numan/0.2.0/bin/numan"); + assert_eq!(classify_binary_path(path), InstallChannel::Homebrew); + } + + #[test] + fn classify_release_archive_path() { + let path = Path::new("/tmp/numan-0.2.0-x86_64-pc-windows-msvc/numan.exe"); + assert_eq!(classify_binary_path(path), InstallChannel::ReleaseArchive); + } + + #[test] + fn conflicting_installs_excludes_paths() { + let excluded = PathBuf::from("/tmp/excluded/numan"); + let installs = vec![ + DiscoveredInstall { + path: excluded.clone(), + channel: InstallChannel::Winget, + }, + DiscoveredInstall { + path: PathBuf::from("/tmp/other/numan"), + channel: InstallChannel::Cargo, + }, + ]; + let filtered = installs + .into_iter() + .filter(|install| !std::slice::from_ref(&excluded).contains(&install.path)) + .filter(|install| install.channel != InstallChannel::Winget) + .collect::>(); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].channel, InstallChannel::Cargo); + } +} diff --git a/install-guard/src/main.rs b/install-guard/src/main.rs new file mode 100644 index 00000000..415ec03b --- /dev/null +++ b/install-guard/src/main.rs @@ -0,0 +1,18 @@ +use std::env; +use std::process::ExitCode; + +use numan_install_guard::{ + run_cargo_install_guard, run_homebrew_install_guard, run_winget_install_guard, +}; + +fn main() -> ExitCode { + match env::args().nth(1).map(|s| s.to_ascii_lowercase()) { + Some(arg) if arg == "cargo" => run_cargo_install_guard(), + Some(arg) if arg == "winget" => run_winget_install_guard(), + Some(arg) if arg == "brew" || arg == "homebrew" => run_homebrew_install_guard(), + _ => { + eprintln!("usage: numan-install-guard "); + ExitCode::from(2) + } + } +} diff --git a/packaging/winget/README.md b/packaging/winget/README.md index 04f1e7c4..d50a0422 100644 --- a/packaging/winget/README.md +++ b/packaging/winget/README.md @@ -20,6 +20,14 @@ After manifests are accepted in [microsoft/winget-pkgs](https://github.com/micro winget install tonythethompson.numan ``` +If you already installed numan via **cargo** or **Homebrew**, use the install guard wrapper so winget prompts to remove the other copy first (plain `winget install` does not): + +```powershell +powershell -File scripts/install-winget.ps1 +``` + +`cargo install numan-cli` runs the same guard automatically during install. + ## Automated updates The [`Publish to WinGet`](../../.github/workflows/winget.yml) workflow submits one update PR after each published GitHub Release. It uses the Windows `.zip` release asset and the existing `tonythethompson/winget-pkgs` fork. diff --git a/scripts/install-cargo.ps1 b/scripts/install-cargo.ps1 new file mode 100644 index 00000000..62c890fb --- /dev/null +++ b/scripts/install-cargo.ps1 @@ -0,0 +1,33 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install numan via cargo after checking for conflicting installs from other channels. + +.DESCRIPTION + Plain `cargo install` already runs the guard via build.rs. Use this wrapper when you + want an explicit pre-check before `cargo install --path .` from a git checkout. +#> +[CmdletBinding()] +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$CargoArgs = @("install", "--path", ".") +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$guardManifest = Join-Path $repoRoot "install-guard\Cargo.toml" + +if (-not (Test-Path $guardManifest)) { + Write-Error "install-guard manifest not found at $guardManifest (run from the numan repo)." +} + +Write-Host "Checking for conflicting numan installs..." +& cargo run --quiet --manifest-path $guardManifest -- cargo +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "Running: cargo $($CargoArgs -join ' ')" +& cargo @CargoArgs +exit $LASTEXITCODE diff --git a/scripts/install-homebrew.sh b/scripts/install-homebrew.sh new file mode 100644 index 00000000..1edca456 --- /dev/null +++ b/scripts/install-homebrew.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Install numan via Homebrew after checking for conflicting installs from other channels. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +guard_manifest="${repo_root}/install-guard/Cargo.toml" + +if [[ ! -f "${guard_manifest}" ]]; then + echo "install-guard manifest not found at ${guard_manifest} (run from the numan repo)." >&2 + exit 1 +fi + +echo "Checking for conflicting numan installs..." +cargo run --quiet --manifest-path "${guard_manifest}" -- brew +echo "Running: brew install tonythethompson/numan/numan ${*}" +brew install tonythethompson/numan/numan "$@" diff --git a/scripts/install-winget.ps1 b/scripts/install-winget.ps1 new file mode 100644 index 00000000..63070db8 --- /dev/null +++ b/scripts/install-winget.ps1 @@ -0,0 +1,38 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install numan via winget after checking for conflicting installs from other channels. + +.DESCRIPTION + Runs the numan install guard (prompt to uninstall cargo/homebrew/release copies first). + Cancels winget install when the user declines or uninstall fails. + + From the numan repository root: + powershell -File scripts/install-winget.ps1 + + Plain `winget install` does not run this guard; prefer this script when switching + from cargo or another package manager. +#> +[CmdletBinding()] +param( + [string[]]$WingetArgs = @("install", "tonythethompson.numan") +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$guardManifest = Join-Path $repoRoot "install-guard\Cargo.toml" + +if (-not (Test-Path $guardManifest)) { + Write-Error "install-guard manifest not found at $guardManifest (run from the numan repo)." +} + +Write-Host "Checking for conflicting numan installs..." +& cargo run --quiet --manifest-path $guardManifest -- winget +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "Running: winget $($WingetArgs -join ' ')" +& winget @WingetArgs +exit $LASTEXITCODE diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 9ae34b7c..fee9e961 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -33,6 +33,7 @@ use crate::state::nupm_import::NupmImportsFile; use crate::state::plugin_deactivate_journal::PendingPluginDeactivate; use crate::state::snapshot::{create_snapshot, SnapshotReason, SnapshotTrigger}; use crate::util::fs_safety::{acquire_mutation_lock, assert_managed_file_owned}; +use numan_install_guard::{classify_binary_path, discover_installations, InstallChannel}; use crate::util::hints::{ self, active_plugin_mutation_gated_doctor_message, registry_none_fix, setup_nu_use_existing, ACTIVE_PLUGIN_MUTATION_GATED_FIX, CMD_ACTIVATE, CMD_DEACTIVATE, CMD_DOCTOR_FIX, CMD_INIT, @@ -195,6 +196,7 @@ pub fn run_checks_with_options( let mut findings = Vec::new(); check_root_layout(root, &mut findings); + check_install_channels(&mut findings); check_active_version_marker(root, &mut findings); let nu_paths = check_nu_paths(root, options, &mut findings); check_nu_environments(root, options, &mut findings); @@ -322,6 +324,59 @@ fn nu_is_available(root: &Path) -> bool { false } +fn check_install_channels(findings: &mut Vec) { + let installs = discover_installations(); + if installs.len() <= 1 { + return; + } + + let channels: std::collections::HashSet = + installs.iter().map(|install| install.channel).collect(); + if channels.len() <= 1 { + return; + } + + let current = std::env::current_exe().ok(); + let current_channel = current + .as_ref() + .map(|path| classify_binary_path(path)) + .unwrap_or(InstallChannel::Unknown); + + let mut lines = vec![ + "Multiple numan installs from different package managers were detected:".to_string(), + ]; + for install in &installs { + lines.push(format!( + " {} ({})", + install.path.display(), + install.channel.label() + )); + } + lines.push(format!( + "This session is running the {} build.", + current_channel.label() + )); + lines.push( + "Keep one install channel to avoid PATH ambiguity. Uninstall the others before reinstalling." + .to_string(), + ); + + let fix = installs + .iter() + .filter(|install| install.channel != current_channel) + .filter_map(|install| install.channel.uninstall_hint()) + .collect::>() + .join("; "); + + findings.push(finding( + "install.multiple_channels", + Severity::Warn, + lines.join("\n"), + if fix.is_empty() { None } else { Some(fix.as_str()) }, + RepairTier::Manual, + )); +} + /// Detect a present-but-unreadable `nu_state/active-version.json`. /// /// `find_nu_executable_with_root` treats marker read errors as soft misses and From e9cc9c67e65081d255ccce10f7d38f140acb65fb Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:24:13 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=203=20findings=20=E2=80=94=20Format=20?= =?UTF-8?q?install=20guard=20sources;=20Remove=20install=20guard=20byp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Format install guard sources - Remove install guard bypass flag - Use PATH-only doctor scanning --- install-guard/src/lib.rs | 26 ++++++++++++-------------- src/cmd/doctor.rs | 5 +++-- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/install-guard/src/lib.rs b/install-guard/src/lib.rs index 1686b31d..590acf80 100644 --- a/install-guard/src/lib.rs +++ b/install-guard/src/lib.rs @@ -81,6 +81,16 @@ pub fn classify_binary_path(path: &Path) -> InstallChannel { } /// Discover `numan` binaries on PATH and in known package-manager locations. +pub fn discover_path_installations() -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for dir in path_directories() { + push_if_numan(&dir, &mut seen, &mut out); + } + out.sort_by(|a, b| a.path.cmp(&b.path)); + out +} + pub fn discover_installations() -> Vec { let mut seen = HashSet::new(); let mut out = Vec::new(); @@ -254,10 +264,6 @@ fn default_cargo_install_root() -> PathBuf { } fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> ExitCode { - if should_skip_guard() { - return ExitCode::SUCCESS; - } - let conflicts = conflicting_installs(channel, exclude_paths); if conflicts.is_empty() { return ExitCode::SUCCESS; @@ -271,7 +277,7 @@ fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> Exit "Refusing {} install in non-interactive session while another channel is installed.", channel.label() ); - eprintln!("Remove the existing install first, or set NUMAN_SKIP_INSTALL_GUARD=1 to bypass."); + eprintln!("Remove the existing install first, then retry the installation."); return ExitCode::from(1); } @@ -312,7 +318,7 @@ fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> Exit } } - let remaining = conflicting_installs(channel, exclude_paths); + let remaining = conflicting_installs(channel, exclude_paths); if !remaining.is_empty() { eprintln!("Existing install still detected after uninstall; cancelling install."); for install in remaining { @@ -324,14 +330,6 @@ fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> Exit ExitCode::SUCCESS } -fn should_skip_guard() -> bool { - std::env::var("NUMAN_SKIP_INSTALL_GUARD") - .map(|v| v == "1") - .unwrap_or(false) - || std::env::var("CI").ok().as_deref() == Some("true") - || std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") -} - fn print_conflict_banner(channel: InstallChannel, conflicts: &[DiscoveredInstall]) { eprintln!(); eprintln!( diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index fee9e961..6393b2d5 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -33,7 +33,7 @@ use crate::state::nupm_import::NupmImportsFile; use crate::state::plugin_deactivate_journal::PendingPluginDeactivate; use crate::state::snapshot::{create_snapshot, SnapshotReason, SnapshotTrigger}; use crate::util::fs_safety::{acquire_mutation_lock, assert_managed_file_owned}; -use numan_install_guard::{classify_binary_path, discover_installations, InstallChannel}; +use numan_install_guard::{classify_binary_path, discover_path_installations, InstallChannel}; use crate::util::hints::{ self, active_plugin_mutation_gated_doctor_message, registry_none_fix, setup_nu_use_existing, ACTIVE_PLUGIN_MUTATION_GATED_FIX, CMD_ACTIVATE, CMD_DEACTIVATE, CMD_DOCTOR_FIX, CMD_INIT, @@ -325,7 +325,8 @@ fn nu_is_available(root: &Path) -> bool { } fn check_install_channels(findings: &mut Vec) { - let installs = discover_installations(); + // Doctor uses the cheap PATH-only scan; package-manager trees are checked by install guards. + let installs = discover_path_installations(); if installs.len() <= 1 { return; } From a620eaa343bf42fa8e3bf6af67bad07ce04b83b6 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:24:18 +0000 Subject: [PATCH 3/5] fix: Make install guard publishable --- Cargo.toml | 5 ++--- install-guard/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ac13ced2..4aeb5154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,14 +17,13 @@ exclude = [ "tests/fixtures/**", "Phase*.md", ".tmp*", - "install-guard/**", ] [build-dependencies] -numan-install-guard = { path = "install-guard" } +numan-install-guard = { path = "install-guard", version = "0.1.0" } [dependencies] -numan-install-guard = { path = "install-guard" } +numan-install-guard = { path = "install-guard", version = "0.1.0" } # CLI clap = { version = "4", features = ["derive"] } clap_complete = "4" diff --git a/install-guard/Cargo.toml b/install-guard/Cargo.toml index 7ece0472..6ac76f6a 100644 --- a/install-guard/Cargo.toml +++ b/install-guard/Cargo.toml @@ -2,7 +2,7 @@ name = "numan-install-guard" version = "0.1.0" edition = "2021" -publish = false +publish = true [[bin]] name = "numan-install-guard" From 9113a7cb033769a34ec19f2a76ed38ab0f7a09a1 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 5 Aug 2026 09:29:13 -0700 Subject: [PATCH 4/5] Fold install guard into main crate for crates.io packaging Move install-channel logic to src/util/install_channel.rs shared by build.rs, doctor, and the numan-install-guard binary. Removes the path-dependency install-guard crate so cargo package and cargo deny succeed. Co-authored-by: Cursor --- AGENTS.md | 4 +- Cargo.lock | 5 -- Cargo.toml | 14 ++-- build.rs | 7 +- install-guard/Cargo.toml | 11 --- scripts/install-cargo.ps1 | 14 +--- scripts/install-homebrew.sh | 8 +-- scripts/install-winget.ps1 | 18 +---- .../src/main.rs => src/bin/install_guard.rs | 2 +- src/cmd/doctor.rs | 15 ++-- .../src/lib.rs => src/util/install_channel.rs | 70 ++++++++++++------- src/util/mod.rs | 1 + 12 files changed, 77 insertions(+), 92 deletions(-) delete mode 100644 install-guard/Cargo.toml rename install-guard/src/main.rs => src/bin/install_guard.rs (93%) rename install-guard/src/lib.rs => src/util/install_channel.rs (88%) diff --git a/AGENTS.md b/AGENTS.md index a78b5ab3..4d7b8796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,8 @@ CI runs `cargo test`, `cargo clippy -- -D warnings`, `cargo fmt --check`, and a ## Project Structure ``` -build.rs — cargo-install guard (cross-channel install detection via install-guard) -install-guard/ — shared install-channel detection + interactive uninstall prompt +build.rs — cargo-install guard (cross-channel install detection) +src/util/install_channel.rs — install-channel detection + interactive uninstall prompt src/ main.rs — CLI entry point (clap-based) config.rs — Config load/save, root resolution diff --git a/Cargo.lock b/Cargo.lock index 230b48a4..2d3b1823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,7 +1213,6 @@ dependencies = [ "hex", "indicatif", "libc", - "numan-install-guard", "rand_core", "reqwest", "semver", @@ -1230,10 +1229,6 @@ dependencies = [ "zip", ] -[[package]] -name = "numan-install-guard" -version = "0.1.0" - [[package]] name = "number_prefix" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 4aeb5154..4371b861 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,15 @@ exclude = [ ".tmp*", ] -[build-dependencies] -numan-install-guard = { path = "install-guard", version = "0.1.0" } +[[bin]] +name = "numan" +path = "src/main.rs" + +[[bin]] +name = "numan-install-guard" +path = "src/bin/install_guard.rs" [dependencies] -numan-install-guard = { path = "install-guard", version = "0.1.0" } # CLI clap = { version = "4", features = ["derive"] } clap_complete = "4" @@ -76,10 +80,6 @@ uuid = { version = "1", features = ["v7", "v4"] } # Git (for source builds and registry sync) git2 = "0.21" -[[bin]] -name = "numan" -path = "src/main.rs" - # stdio redirect for doctor --json repair passes (Unix only) [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/build.rs b/build.rs index 4f519dd9..300e6e36 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,11 @@ +mod install_channel { + #![allow(dead_code)] + include!("src/util/install_channel.rs"); +} + fn main() { if std::env::var("CARGO_INSTALL_ROOT").is_ok() - && numan_install_guard::run_cargo_install_guard() != std::process::ExitCode::SUCCESS + && install_channel::run_cargo_install_guard() != std::process::ExitCode::SUCCESS { std::process::exit(1); } diff --git a/install-guard/Cargo.toml b/install-guard/Cargo.toml deleted file mode 100644 index 6ac76f6a..00000000 --- a/install-guard/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "numan-install-guard" -version = "0.1.0" -edition = "2021" -publish = true - -[[bin]] -name = "numan-install-guard" -path = "src/main.rs" - -[dependencies] diff --git a/scripts/install-cargo.ps1 b/scripts/install-cargo.ps1 index 62c890fb..b36910a4 100644 --- a/scripts/install-cargo.ps1 +++ b/scripts/install-cargo.ps1 @@ -2,10 +2,6 @@ <# .SYNOPSIS Install numan via cargo after checking for conflicting installs from other channels. - -.DESCRIPTION - Plain `cargo install` already runs the guard via build.rs. Use this wrapper when you - want an explicit pre-check before `cargo install --path .` from a git checkout. #> [CmdletBinding()] param( @@ -16,18 +12,14 @@ param( $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot -$guardManifest = Join-Path $repoRoot "install-guard\Cargo.toml" - -if (-not (Test-Path $guardManifest)) { - Write-Error "install-guard manifest not found at $guardManifest (run from the numan repo)." -} +$manifest = Join-Path $repoRoot "Cargo.toml" Write-Host "Checking for conflicting numan installs..." -& cargo run --quiet --manifest-path $guardManifest -- cargo +& cargo run --quiet --bin numan-install-guard --manifest-path $manifest -- cargo if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "Running: cargo $($CargoArgs -join ' ')" -& cargo @CargoArgs +& cargo @CargoArgs --manifest-path $manifest exit $LASTEXITCODE diff --git a/scripts/install-homebrew.sh b/scripts/install-homebrew.sh index 1edca456..3803eeee 100644 --- a/scripts/install-homebrew.sh +++ b/scripts/install-homebrew.sh @@ -3,14 +3,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -guard_manifest="${repo_root}/install-guard/Cargo.toml" - -if [[ ! -f "${guard_manifest}" ]]; then - echo "install-guard manifest not found at ${guard_manifest} (run from the numan repo)." >&2 - exit 1 -fi echo "Checking for conflicting numan installs..." -cargo run --quiet --manifest-path "${guard_manifest}" -- brew +cargo run --quiet --bin numan-install-guard --manifest-path "${repo_root}/Cargo.toml" -- brew echo "Running: brew install tonythethompson/numan/numan ${*}" brew install tonythethompson/numan/numan "$@" diff --git a/scripts/install-winget.ps1 b/scripts/install-winget.ps1 index 63070db8..38719394 100644 --- a/scripts/install-winget.ps1 +++ b/scripts/install-winget.ps1 @@ -2,16 +2,6 @@ <# .SYNOPSIS Install numan via winget after checking for conflicting installs from other channels. - -.DESCRIPTION - Runs the numan install guard (prompt to uninstall cargo/homebrew/release copies first). - Cancels winget install when the user declines or uninstall fails. - - From the numan repository root: - powershell -File scripts/install-winget.ps1 - - Plain `winget install` does not run this guard; prefer this script when switching - from cargo or another package manager. #> [CmdletBinding()] param( @@ -21,14 +11,10 @@ param( $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot -$guardManifest = Join-Path $repoRoot "install-guard\Cargo.toml" - -if (-not (Test-Path $guardManifest)) { - Write-Error "install-guard manifest not found at $guardManifest (run from the numan repo)." -} +$manifest = Join-Path $repoRoot "Cargo.toml" Write-Host "Checking for conflicting numan installs..." -& cargo run --quiet --manifest-path $guardManifest -- winget +& cargo run --quiet --bin numan-install-guard --manifest-path $manifest -- winget if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/install-guard/src/main.rs b/src/bin/install_guard.rs similarity index 93% rename from install-guard/src/main.rs rename to src/bin/install_guard.rs index 415ec03b..e60366cf 100644 --- a/install-guard/src/main.rs +++ b/src/bin/install_guard.rs @@ -1,7 +1,7 @@ use std::env; use std::process::ExitCode; -use numan_install_guard::{ +use numan_cli::util::install_channel::{ run_cargo_install_guard, run_homebrew_install_guard, run_winget_install_guard, }; diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 6393b2d5..dfd854eb 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -33,12 +33,14 @@ use crate::state::nupm_import::NupmImportsFile; use crate::state::plugin_deactivate_journal::PendingPluginDeactivate; use crate::state::snapshot::{create_snapshot, SnapshotReason, SnapshotTrigger}; use crate::util::fs_safety::{acquire_mutation_lock, assert_managed_file_owned}; -use numan_install_guard::{classify_binary_path, discover_path_installations, InstallChannel}; use crate::util::hints::{ self, active_plugin_mutation_gated_doctor_message, registry_none_fix, setup_nu_use_existing, ACTIVE_PLUGIN_MUTATION_GATED_FIX, CMD_ACTIVATE, CMD_DEACTIVATE, CMD_DOCTOR_FIX, CMD_INIT, CMD_INIT_REFRESH, CMD_REGISTRY_SYNC, CMD_SETUP_NU, CMD_USE, }; +use crate::util::install_channel::{ + classify_binary_path, discover_path_installations, InstallChannel, +}; use crate::util::stdio_redirect::StdoutToStderr; const SCHEMA_VERSION: u32 = 1; @@ -343,9 +345,8 @@ fn check_install_channels(findings: &mut Vec) { .map(|path| classify_binary_path(path)) .unwrap_or(InstallChannel::Unknown); - let mut lines = vec![ - "Multiple numan installs from different package managers were detected:".to_string(), - ]; + let mut lines = + vec!["Multiple numan installs from different package managers were detected:".to_string()]; for install in &installs { lines.push(format!( " {} ({})", @@ -373,7 +374,11 @@ fn check_install_channels(findings: &mut Vec) { "install.multiple_channels", Severity::Warn, lines.join("\n"), - if fix.is_empty() { None } else { Some(fix.as_str()) }, + if fix.is_empty() { + None + } else { + Some(fix.as_str()) + }, RepairTier::Manual, )); } diff --git a/install-guard/src/lib.rs b/src/util/install_channel.rs similarity index 88% rename from install-guard/src/lib.rs rename to src/util/install_channel.rs index 590acf80..ce7c7ce1 100644 --- a/install-guard/src/lib.rs +++ b/src/util/install_channel.rs @@ -1,5 +1,5 @@ -//! Detect Numan binaries installed via different package managers and gate -//! cross-channel installs behind an interactive uninstall prompt. +// Detect Numan binaries installed via different package managers and gate +// cross-channel installs behind an interactive uninstall prompt. use std::collections::HashSet; use std::ffi::OsStr; @@ -47,11 +47,12 @@ pub struct DiscoveredInstall { /// Classify a binary path by install channel heuristics. pub fn classify_binary_path(path: &Path) -> InstallChannel { - let normalized = path.to_string_lossy().replace('\\', "/").to_ascii_lowercase(); + let normalized = path + .to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase(); - if normalized.contains("/.cargo/bin/numan") - || normalized.ends_with("/.cargo/bin/numan.exe") - { + if normalized.contains("/.cargo/bin/numan") || normalized.ends_with("/.cargo/bin/numan.exe") { return InstallChannel::Cargo; } @@ -110,10 +111,7 @@ pub fn discover_installations() -> Vec { } fn push_if_numan(dir: &Path, seen: &mut HashSet, out: &mut Vec) { - let candidates = [ - dir.join("numan"), - dir.join("numan.exe"), - ]; + let candidates = [dir.join("numan"), dir.join("numan.exe")]; for candidate in candidates { if candidate.is_file() { push_install(candidate, seen, out); @@ -152,7 +150,10 @@ fn known_install_candidates() -> Vec { if cfg!(windows) { if let Some(local) = std::env::var_os("LOCALAPPDATA") { - let winget_packages = PathBuf::from(local).join("Microsoft").join("WinGet").join("Packages"); + let winget_packages = PathBuf::from(local) + .join("Microsoft") + .join("WinGet") + .join("Packages"); candidates.extend(find_numan_under(&winget_packages, 4)); } } @@ -160,8 +161,14 @@ fn known_install_candidates() -> Vec { if cfg!(target_os = "macos") { candidates.push(PathBuf::from("/opt/homebrew/Cellar/numan")); candidates.push(PathBuf::from("/usr/local/Cellar/numan")); - candidates.extend(find_numan_under(&PathBuf::from("/opt/homebrew/Cellar/numan"), 3)); - candidates.extend(find_numan_under(&PathBuf::from("/usr/local/Cellar/numan"), 3)); + candidates.extend(find_numan_under( + &PathBuf::from("/opt/homebrew/Cellar/numan"), + 3, + )); + candidates.extend(find_numan_under( + &PathBuf::from("/usr/local/Cellar/numan"), + 3, + )); } if cfg!(target_os = "linux") { @@ -169,7 +176,10 @@ fn known_install_candidates() -> Vec { let linuxbrew = home.join(".linuxbrew/Cellar/numan"); candidates.extend(find_numan_under(&linuxbrew, 3)); } - candidates.extend(find_numan_under(&PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/numan"), 3)); + candidates.extend(find_numan_under( + &PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/numan"), + 3, + )); } candidates @@ -203,10 +213,10 @@ fn walk_for_numan(dir: &Path, depth: usize, max_depth: usize, found: &mut Vec bool { - match path.file_name().and_then(OsStr::to_str) { - Some("numan") | Some("numan.exe") => true, - _ => false, - } + matches!( + path.file_name().and_then(OsStr::to_str), + Some("numan") | Some("numan.exe") + ) } fn home_dir() -> Option { @@ -237,11 +247,9 @@ pub fn run_cargo_install_guard() -> ExitCode { let install_root = std::env::var("CARGO_INSTALL_ROOT") .map(PathBuf::from) .unwrap_or_else(|_| default_cargo_install_root()); - let target = install_root.join("bin").join(if cfg!(windows) { - "numan.exe" - } else { - "numan" - }); + let target = install_root + .join("bin") + .join(if cfg!(windows) { "numan.exe" } else { "numan" }); run_install_guard(InstallChannel::Cargo, &[target]) } @@ -264,6 +272,10 @@ fn default_cargo_install_root() -> PathBuf { } fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> ExitCode { + if should_skip_guard() { + return ExitCode::SUCCESS; + } + let conflicts = conflicting_installs(channel, exclude_paths); if conflicts.is_empty() { return ExitCode::SUCCESS; @@ -330,6 +342,14 @@ fn run_install_guard(channel: InstallChannel, exclude_paths: &[PathBuf]) -> Exit ExitCode::SUCCESS } +fn should_skip_guard() -> bool { + std::env::var("NUMAN_SKIP_INSTALL_GUARD") + .map(|v| v == "1") + .unwrap_or(false) + || std::env::var("CI").ok().as_deref() == Some("true") + || std::env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true") +} + fn print_conflict_banner(channel: InstallChannel, conflicts: &[DiscoveredInstall]) { eprintln!(); eprintln!( @@ -390,9 +410,7 @@ mod tests { #[test] fn classify_winget_path() { - let path = Path::new( - "C:/Users/x/AppData/Local/Microsoft/WinGet/Packages/foo/numan.exe", - ); + let path = Path::new("C:/Users/x/AppData/Local/Microsoft/WinGet/Packages/foo/numan.exe"); assert_eq!(classify_binary_path(path), InstallChannel::Winget); } diff --git a/src/util/mod.rs b/src/util/mod.rs index b7b45f00..1e40957f 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -2,6 +2,7 @@ pub mod atomic; pub mod confirm; pub mod fs_safety; pub mod hints; +pub mod install_channel; pub mod stdio_redirect; /// PATH snapshot/restore for unit and integration tests that mutate process env. pub mod test_paths; From 5c8d2dbaf473f1cd6780bc22cc48bec67a249e44 Mon Sep 17 00:00:00 2001 From: tonythethompson Date: Wed, 5 Aug 2026 09:34:21 -0700 Subject: [PATCH 5/5] Address install guard review feedback Detect cargo install via OUT_DIR cargo-install path when CARGO_INSTALL_ROOT is unset. Deduplicate doctor uninstall hints and fix docs capitalization. Co-authored-by: Cursor --- build.rs | 15 ++++++++++++++- docs/numan-doctor.md | 2 +- src/cmd/doctor.rs | 2 ++ src/util/install_channel.rs | 20 +++++++++++--------- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/build.rs b/build.rs index 300e6e36..16d36f84 100644 --- a/build.rs +++ b/build.rs @@ -3,8 +3,21 @@ mod install_channel { include!("src/util/install_channel.rs"); } +fn is_cargo_install_build() -> bool { + if std::env::var("CARGO_INSTALL_ROOT").is_ok() { + return true; + } + if let Ok(out_dir) = std::env::var("OUT_DIR") { + let normalized = out_dir.replace('\\', "/").to_ascii_lowercase(); + if normalized.contains("cargo-install") { + return true; + } + } + false +} + fn main() { - if std::env::var("CARGO_INSTALL_ROOT").is_ok() + if is_cargo_install_build() && install_channel::run_cargo_install_guard() != std::process::ExitCode::SUCCESS { std::process::exit(1); diff --git a/docs/numan-doctor.md b/docs/numan-doctor.md index 5413b8e5..4ce510bb 100644 --- a/docs/numan-doctor.md +++ b/docs/numan-doctor.md @@ -129,7 +129,7 @@ Checks run in order below. Implementation should call existing validators (`NuPa |----|----------|-----------| | `nu.binary.missing_on_path` | `error` | Nu not on PATH and not under `$NUMAN_ROOT/tools/nushell/` → fix: `numan setup nu` | | `nu.binary.found_off_path` | `warn` | Nu exists in a known install root (e.g. `~/.cargo/bin`, `%LOCALAPPDATA%\Programs\nushell`) but not on PATH → fix: `numan setup nu use ` | -| `install.multiple_channels` | `warn` | More than one `numan` binary from different install channels (cargo, winget, homebrew, release archive) → fix: uninstall the other channel(s) before reinstalling | +| `install.multiple_channels` | `warn` | More than one `numan` binary from different install channels (cargo, winget, Homebrew, release archive) → fix: uninstall the other channel(s) before reinstalling | | `nu.path.version` | `info` | PATH-only Nu version (`PATH Nu: 0.114.1`), `PATH Nu: not found`, or `PATH Nu: found at '' but version probe failed ()` when the binary exists but `--version` fails. Does not treat managed Nu as PATH. Report-only (no automatic repair). | | `nu.managed.version` | `info` | Managed binary under `$NUMAN_ROOT/tools/nushell/` with version, `Managed Nu: not installed`, or `Managed Nu: present at '' but version probe failed ()` when the binary exists but `--version` fails. Report-only (no automatic repair). | | `nu.active_version.invalid` | `error` | `nu_state/active-version.json` is present but unreadable/invalid JSON. Lookup would otherwise soft-miss the marker and fall back to PATH. **auto:** copy raw bytes to `active-version.json.corrupt` (best-effort, recoverable `binary_path`), then clear via `clear_active_version` so resolution recovers cleanly. | diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index dfd854eb..aff1641d 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -367,6 +367,8 @@ fn check_install_channels(findings: &mut Vec) { .iter() .filter(|install| install.channel != current_channel) .filter_map(|install| install.channel.uninstall_hint()) + .collect::>() + .into_iter() .collect::>() .join("; "); diff --git a/src/util/install_channel.rs b/src/util/install_channel.rs index ce7c7ce1..c62bf6dd 100644 --- a/src/util/install_channel.rs +++ b/src/util/install_channel.rs @@ -429,22 +429,24 @@ mod tests { #[test] fn conflicting_installs_excludes_paths() { let excluded = PathBuf::from("/tmp/excluded/numan"); - let installs = vec![ + let other = PathBuf::from("/tmp/other/numan"); + let channel = InstallChannel::Winget; + let filtered = [ DiscoveredInstall { path: excluded.clone(), - channel: InstallChannel::Winget, + channel: InstallChannel::Cargo, }, DiscoveredInstall { - path: PathBuf::from("/tmp/other/numan"), + path: other.clone(), channel: InstallChannel::Cargo, }, - ]; - let filtered = installs - .into_iter() - .filter(|install| !std::slice::from_ref(&excluded).contains(&install.path)) - .filter(|install| install.channel != InstallChannel::Winget) - .collect::>(); + ] + .into_iter() + .filter(|install| !std::slice::from_ref(&excluded).contains(&install.path)) + .filter(|install| install.channel != channel) + .collect::>(); assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].path, other); assert_eq!(filtered[0].channel, InstallChannel::Cargo); } }