Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ exclude = [
name = "numan"
path = "src/main.rs"

[[bin]]
name = "numan-install-guard"
path = "src/bin/install_guard.rs"

[dependencies]
# CLI
clap = { version = "4", features = ["derive"] }
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,16 @@ 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)

```powershell
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
Expand All @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
mod install_channel {
#![allow(dead_code)]
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 is_cargo_install_build()
&& install_channel::run_cargo_install_guard() != std::process::ExitCode::SUCCESS
{
std::process::exit(1);
}
}
18 changes: 15 additions & 3 deletions docs/PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-<target>/` containing the `numan`
(or `numan.exe`) binary. Homebrew and winget installers assume this layout.
Expand Down
1 change: 1 addition & 0 deletions docs/numan-doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` |
| `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 '<path>' but version probe failed (<error>)` 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 '<path>' but version probe failed (<error>)` 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. |
Expand Down
8 changes: 8 additions & 0 deletions packaging/winget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions scripts/install-cargo.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Install numan via cargo after checking for conflicting installs from other channels.
#>
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$CargoArgs = @("install", "--path", ".")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Resolve the default Cargo path from the script directory.

$repoRoot uses the script location, but the default --path . uses the caller’s current directory. If a user invokes this wrapper outside the checkout, Cargo installs the wrong directory or fails to find a manifest. Set the default path after $repoRoot is known.

Proposed fix
 $repoRoot = Split-Path -Parent $PSScriptRoot
 $guardManifest = Join-Path $repoRoot "install-guard\Cargo.toml"
+
+if (-not $PSBoundParameters.ContainsKey("CargoArgs")) {
+    $CargoArgs = @("install", "--path", $repoRoot)
+}

Also applies to: 18-19, 31-32

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-cargo.ps1` at line 13, Update the install-cargo wrapper so
the default Cargo --path is derived from the script’s resolved repository root
instead of the caller’s current directory. In the install flow around $repoRoot
and $CargoArgs, set or rewrite the default path after $repoRoot is established
so cargo install always targets the checkout the script belongs to, while
preserving any explicit path overrides.

)

$ErrorActionPreference = "Stop"

$repoRoot = Split-Path -Parent $PSScriptRoot
$manifest = Join-Path $repoRoot "Cargo.toml"

Write-Host "Checking for conflicting numan installs..."
& 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 --manifest-path $manifest
exit $LASTEXITCODE
10 changes: 10 additions & 0 deletions scripts/install-homebrew.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/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)"

echo "Checking for conflicting numan installs..."
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 "$@"
Comment on lines +7 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Homebrew install ignores numan_root 📘 Rule violation ≡ Correctness

The install-time scripts scripts/install-homebrew.sh, scripts/install-winget.ps1, and
scripts/install-cargo.ps1 invoke brew install, winget install, and cargo install,
respectively, which perform installations into tool/system-managed locations not constrained under
$NUMAN_ROOT. Compliance requires install-time code to keep all filesystem writes confined to paths
rooted under $NUMAN_ROOT.
Agent Prompt
## Issue description
`scripts/install-homebrew.sh`, `scripts/install-winget.ps1`, and `scripts/install-cargo.ps1` run `brew install`, `winget install`, and `cargo install`, respectively, which perform filesystem mutations outside `$NUMAN_ROOT`.

## Issue Context
Compliance requires install-time scripts/hooks to avoid Nu integration APIs and to keep all filesystem writes confined to paths rooted under `$NUMAN_ROOT`.

## Fix Focus Areas
- scripts/install-homebrew.sh[13-16]
- scripts/install-winget.ps1[36-38]
- scripts/install-cargo.ps1[31-33]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

24 changes: 24 additions & 0 deletions scripts/install-winget.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Install numan via winget after checking for conflicting installs from other channels.
#>
[CmdletBinding()]
param(
[string[]]$WingetArgs = @("install", "tonythethompson.numan")
)

$ErrorActionPreference = "Stop"

$repoRoot = Split-Path -Parent $PSScriptRoot
$manifest = Join-Path $repoRoot "Cargo.toml"

Write-Host "Checking for conflicting numan installs..."
& cargo run --quiet --bin numan-install-guard --manifest-path $manifest -- winget
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

Write-Host "Running: winget $($WingetArgs -join ' ')"
& winget @WingetArgs
exit $LASTEXITCODE
18 changes: 18 additions & 0 deletions src/bin/install_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use std::env;
use std::process::ExitCode;

use numan_cli::util::install_channel::{
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 <cargo|winget|brew>");
ExitCode::from(2)
}
}
}
Comment on lines +8 to +18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'Cargo.toml|main.rs' . | sed 's#^\./##'

echo
echo "== install-guard Cargo.toml =="
cat -n install-guard/Cargo.toml

echo
echo "== install-guard main.rs =="
cat -n install-guard/src/main.rs

echo
echo "== clap usages in repo =="
rg -n "use clap|clap::|Command|Subcommand" --glob '*.rs' .

echo
echo "== cargo dependencies containing clap =="
rg -n "clap\s*=|dependencies" Cargo.toml install-guard/Cargo.toml

Repository: tonythethompson/numan

Length of output: 15778


🏁 Script executed (no clone):

#!/bin/bash
set -euo pipefail

echo "== crates.io clap 4.6.5 derive feature metadata =="
curl -s https://crates.io/api/v1/crates/clap/4.6.5 --fail | jq '{name, version, features}'

echo
echo "== crates.io clap 4.6.5 Cargo.toml features =="
curl -sL https://crates.io/api/v1/crates/clap/4.6.5/download | tar -xOzf - clap-4.6.5/Cargo.toml | sed -n '/\[features\]/,/^$/p'

Length of output: 178


🌐 Web query:

clap Rust derive feature version 4 documentation

💡 Result:

To use the derive API in clap version 4, you must enable the derive feature flag [1][2]. You can enable this feature by adding clap to your Cargo.toml with the derive feature enabled [2]: cargo add clap --features derive Once enabled, the derive API allows you to define your command-line interface using structs, enums, and derive macros such as #[derive(Parser)], #[derive(Args)], #[derive(Subcommand)], and #[derive(ValueEnum)] [1][3]. The derive feature works by depending on the clap_derive crate [4]. You can refer to the official clap documentation, specifically the Derive Reference and the Derive Tutorial, for detailed usage examples and configuration options [1][2][5].

Citations:


Use clap derives for the install guard CLI.

install-guard/src/main.rs manually matches env::args().nth(1), and install-guard/Cargo.toml has no CLI dependencies. Move this command into install-guard/src/cli.rs with #[derive(Parser)]/Subcommand, add clap = "4", and call it from main. No change should mutate install-guard/Cargo.lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@install-guard/src/main.rs` around lines 8 - 18, Replace the manual argument
matching in main with a clap-based CLI module: add install-guard/src/cli.rs
defining Parser and Subcommand-derived types for cargo, winget, and
brew/homebrew commands, add the clap 4 dependency to Cargo.toml, and have main
parse and dispatch the CLI values. Preserve the existing usage/error behavior
where applicable, and do not modify Cargo.lock.

Source: Coding guidelines

63 changes: 63 additions & 0 deletions src/cmd/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ use crate::util::hints::{
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;
Expand Down Expand Up @@ -195,6 +198,7 @@ pub fn run_checks_with_options(
let mut findings = Vec::new();

check_root_layout(root, &mut findings);
check_install_channels(&mut findings);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
check_active_version_marker(root, &mut findings);
let nu_paths = check_nu_paths(root, options, &mut findings);
check_nu_environments(root, options, &mut findings);
Expand Down Expand Up @@ -322,6 +326,65 @@ fn nu_is_available(root: &Path) -> bool {
false
}

fn check_install_channels(findings: &mut Vec<Finding>) {
// 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;
}

let channels: std::collections::HashSet<InstallChannel> =
installs.iter().map(|install| install.channel).collect();
if channels.len() <= 1 {
return;
Comment on lines +329 to +339

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Avoid duplicate uninstall hints when multiple installs share the same channel.

fix currently aggregates uninstall_hint() for each conflicting install, so multiple installs from the same channel (e.g., two cargo builds) produce repeated commands. Please deduplicate uninstall commands per InstallChannel before joining, either by collecting hints into a HashSet or by deduping channels first and then mapping to hints.

Suggested implementation:

fn check_install_channels(findings: &mut Vec<Finding>) {
    let installs = discover_installations();
    if installs.len() <= 1 {
        return;
    }

    // Collect all distinct install channels
    let channels: std::collections::HashSet<InstallChannel> =
        installs.iter().map(|install| install.channel).collect();
    if channels.len() <= 1 {
        return;
    }

    // Deduplicate uninstall hints per InstallChannel by:
    // 1. Iterating over each unique channel
    // 2. Picking one representative install for that channel
    // 3. Collecting its uninstall_hint() into a HashSet<String>
    //
    // The resulting `_uninstall_hints` set can be used when constructing
    // the Finding's fix to avoid duplicated uninstall commands.
    let _uninstall_hints: std::collections::HashSet<String> = channels
        .into_iter()
        .filter_map(|channel| {
            installs
                .iter()
                .find(|install| install.channel == channel)
                .map(|install| install.uninstall_hint())
        })
        .collect();

To fully apply the deduplicated uninstall hints in the fix:

  1. Replace any existing logic that aggregates uninstall commands like:
    let fixes = installs.iter().map(|i| i.uninstall_hint()).collect::<Vec<_>>().join("\n");
    with code that uses the _uninstall_hints set, e.g. by:
    • Converting _uninstall_hints into a Vec<String>, sorting if desired for determinism, and then joining with "\n".
  2. Ensure that the Finding created inside check_install_channels (or any helper it calls) uses this joined string of deduplicated hints as the fix text.
  3. If InstallChannel does not yet implement Eq + Hash + Copy/Clone, derive or implement these traits so it can be used as a key in HashSet<InstallChannel>.

Fix in Cursor

}

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::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.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
Expand Down
Loading
Loading