Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,19 @@ A small Rust CLI (`how <command>`) that reports which package manager(s) install
- `cargo fmt --check` — formatting must match.
- `cargo test` if tests exist for the area touched.
- For behavioral changes, run `cargo run -- <command>` against a real command on this machine and confirm the output.
- For changes that affect Linux package managers (apt/dnf/pacman) or distro-specific resolution, run the relevant e2e image — see below.
5. **Summarize** what changed in 1–2 sentences.

## End-to-end tests

Real package-manager behavior is exercised in containers under `tests/e2e/`. CI runs all three on every PR via `.github/workflows/e2e.yml`.

- **Layout**: one Dockerfile per distro (`debian.Dockerfile`, `arch.Dockerfile`, `fedora.Dockerfile`) installs a known package via each supported manager, then runs `tests/e2e/cases/<distro>.sh` as the container's CMD. Each case installs a package via one manager and asserts `how <cmd>` reports that manager (via the `assert_how` helper).
- **Run locally**: `./tests/e2e/run.sh <debian|arch|fedora>` builds the image and runs the assertions. Requires Docker. First build is slow (cold layer cache); reruns are fast.
- **When to run**: any change to `command_resolver.rs`, the `PackageManager` trait, or a Linux-specific manager module (apt, dnf, pacman, snap, and the cross-platform ones to a lesser extent). macOS-only managers (brew on Darwin, MacPorts) aren't covered — smoke-test those manually.
- **When adding a new manager**: add an install step to whichever distro Dockerfile(s) ship it, plus an `assert_how` line to the matching `cases/<distro>.sh`. Pick a package that *only* that manager would install, to avoid cross-attribution noise — except where shadowing is the point of the test.
- **Debugging a failure**: `docker run --rm -it --entrypoint bash how-e2e-<distro>` drops you into the built image with `how` on PATH; rerun the failing `how <cmd>` by hand.

## Adding a new package manager

The repeatable shape, derived from existing modules:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ cargo install --git https://github.com/danilofuchs/how.git
- asdf
- mise
- nvm
- corepack
- pyenv
- rbenv

Expand Down
17 changes: 10 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod package_managers {
pub mod brew;
pub mod bun;
pub mod cargo;
pub mod corepack;
pub mod dnf;
pub mod gem;
pub mod go;
Expand All @@ -24,12 +25,13 @@ mod package_managers {
}
use crate::package_managers::{
apt::AptPackageManager, asdf::AsdfPackageManager, brew::BrewPackageManager,
bun::BunPackageManager, cargo::CargoPackageManager, dnf::DnfPackageManager,
gem::GemPackageManager, go::GoPackageManager, macports::MacPortsPackageManager,
mise::MisePackageManager, npm::NpmPackageManager, nvm::NvmPackageManager,
pacman::PacmanPackageManager, pip::PipPackageManager, pipx::PipxPackageManager,
pnpm::PnpmPackageManager, pyenv::PyenvPackageManager, rbenv::RbenvPackageManager,
snapcraft::SnapCraftPackageManager, uv::UvPackageManager, yarn::YarnPackageManager,
bun::BunPackageManager, cargo::CargoPackageManager, corepack::CorepackPackageManager,
dnf::DnfPackageManager, gem::GemPackageManager, go::GoPackageManager,
macports::MacPortsPackageManager, mise::MisePackageManager, npm::NpmPackageManager,
nvm::NvmPackageManager, pacman::PacmanPackageManager, pip::PipPackageManager,
pipx::PipxPackageManager, pnpm::PnpmPackageManager, pyenv::PyenvPackageManager,
rbenv::RbenvPackageManager, snapcraft::SnapCraftPackageManager, uv::UvPackageManager,
yarn::YarnPackageManager,
};
use package_manager::{PackageManager, ResolvedCommand};

Expand Down Expand Up @@ -58,6 +60,7 @@ fn all_package_managers() -> Vec<Box<dyn PackageManager + Send + Sync>> {
Box::new(NvmPackageManager),
Box::new(BrewPackageManager),
Box::new(CargoPackageManager),
Box::new(CorepackPackageManager),
Box::new(SnapCraftPackageManager),
Box::new(PipPackageManager { bin: "pip" }),
Box::new(PipPackageManager { bin: "pip3" }),
Expand Down Expand Up @@ -181,7 +184,7 @@ async fn main() {
}

if installers.is_empty() && !type_found {
eprintln!("Failed to find package that installed command {}", command);
eprintln!("{}: command not found", command);
exit(1)
}
}
73 changes: 73 additions & 0 deletions src/package_managers/corepack.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use crate::{
command_resolver::command_exists,
package_manager::{PackageManager, ResolvedCommand},
};

/// Commands that corepack manages as shims. Anything else can't have come
/// from corepack regardless of where it lives on disk.
const COREPACK_SHIMS: &[&str] = &["yarn", "yarnpkg", "pnpm", "pnpx"];

pub struct CorepackPackageManager;

impl PackageManager for CorepackPackageManager {
fn name(&self) -> &str {
"corepack"
}

fn is_installed(&self) -> bool {
command_exists("corepack")
}

fn is_command_installed(&self, cmd: &ResolvedCommand) -> Result<bool, String> {
if !COREPACK_SHIMS.contains(&cmd.lookup_name()) {
return Ok(false);
}
let path = match cmd.path() {
Some(p) => p,
None => return Ok(false),
};

// Newer corepack stores shims under $COREPACK_HOME (default
// ~/.cache/node/corepack on Linux, ~/Library/Caches/node/corepack
// on macOS).
if let Some(home) = corepack_home() {
if path.starts_with(&format!("{}/", home)) {
return Ok(true);
}
}

// Otherwise corepack installs shims as symlinks into the corepack
// npm package directory, e.g.
// /usr/bin/yarn -> ../lib/node_modules/corepack/dist/yarn.js
// The shim's *target basename* is `yarn.js`/`pnpm.js`/etc., not
// `corepack` — what's invariant is that the resolved target lives
// inside a directory called `corepack`.
let p = std::path::Path::new(path);
let target = match std::fs::read_link(p) {
Ok(t) => t,
Err(_) => return Ok(false),
};
let joined = match p.parent() {
Some(parent) => parent.join(&target),
None => target.clone(),
};
let resolved = std::fs::canonicalize(&joined).unwrap_or(joined);
Ok(resolved
.components()
.any(|c| c.as_os_str() == std::ffi::OsStr::new("corepack")))
}
}

fn corepack_home() -> Option<String> {
if let Ok(dir) = std::env::var("COREPACK_HOME") {
if !dir.is_empty() {
return Some(dir);
}
}
let home = std::env::var("HOME").ok()?;
if cfg!(target_os = "macos") {
Some(format!("{}/Library/Caches/node/corepack", home))
} else {
Some(format!("{}/.cache/node/corepack", home))
}
}
18 changes: 18 additions & 0 deletions tests/e2e/cases/debian.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export PATH="$GOBIN:$PATH"
go install github.com/rakyll/hey@latest
assert_how hey go

section "corepack"
# `corepack enable yarn` (run in the Dockerfile) installed a yarn shim
# next to corepack itself. The shim is a symlink whose target's filename
# is `corepack`, which is what the corepack detector keys on.
assert_how yarn corepack

section "nvm + node shadowing"
# Both apt-node (/usr/bin/node) and nvm-node ($NVM_DIR/versions/node/.../bin/node)
# exist. Whichever manager owns the resolved path wins. nvm's init prepends
Expand All @@ -84,6 +90,18 @@ nvm install 24 >/dev/null
nvm use 24 >/dev/null
assert_how node nvm

section "uninstalled command"
# A command that isn't on PATH should report "command not found", not the
# misleading "Failed to find package that installed command ...".
out=$(how definitely-not-a-real-cmd-xyz 2>&1) || true
if echo "$out" | grep -q "command not found"; then
echo "OK: how <missing> → command not found"
else
echo "FAIL: how <missing> did not report 'command not found'"
echo "$out" | sed 's/^/ | /'
fail=$((fail + 1))
fi

echo
if (( fail > 0 )); then
echo "$fail assertion(s) failed"
Expand Down
13 changes: 9 additions & 4 deletions tests/e2e/debian.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unzip xz-utils \
&& rm -rf /var/lib/apt/lists/*

# 2. Node.js 24 via NodeSource → gives `node` and `npm`. corepack is
# deprecated upstream, so install pnpm via its standalone installer
# instead. (yarn is intentionally skipped for now — Berry has no
# `global` and classic-yarn-via-npm collides with the npm test.)
# 2. Node.js 24 via NodeSource → gives `node`, `npm`, and `corepack`.
# `pnpm` is installed via its standalone installer below (separate from
# corepack) so the pnpm test attributes to pnpm itself; corepack is
# exercised separately by enabling its `yarn` shim.
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*

# corepack ships with Node. `corepack enable yarn` creates a `yarn`
# symlink next to corepack itself (e.g. /usr/bin/yarn → corepack), which
# is the shape `how`'s corepack detector matches.
RUN corepack enable yarn

ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN curl -fsSL https://get.pnpm.io/install.sh | env SHELL=/bin/bash sh -
Expand Down
Loading