From ce4d5ff86fd4db1d753ff2fc85f8a444b89c0527 Mon Sep 17 00:00:00 2001 From: Danilo Campana Fuchs Date: Sat, 2 May 2026 15:07:12 -0300 Subject: [PATCH] feat: support corepack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects yarn/yarnpkg/pnpm/pnpx shims created by `corepack enable` — either symlinks resolving into the corepack package directory, or files cached under $COREPACK_HOME. Covered by a new debian e2e case that enables the corepack yarn shim and asserts attribution. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 +++++ README.md | 1 + src/main.rs | 17 +++++--- src/package_managers/corepack.rs | 73 ++++++++++++++++++++++++++++++++ tests/e2e/cases/debian.sh | 18 ++++++++ tests/e2e/debian.Dockerfile | 13 ++++-- 6 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 src/package_managers/corepack.rs diff --git a/CLAUDE.md b/CLAUDE.md index 482e21d..1404795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,19 @@ A small Rust CLI (`how `) 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 -- ` 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/.sh` as the container's CMD. Each case installs a package via one manager and asserts `how ` reports that manager (via the `assert_how` helper). +- **Run locally**: `./tests/e2e/run.sh ` 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/.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-` drops you into the built image with `how` on PATH; rerun the failing `how ` by hand. + ## Adding a new package manager The repeatable shape, derived from existing modules: diff --git a/README.md b/README.md index 31b878c..fd0e577 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ cargo install --git https://github.com/danilofuchs/how.git - asdf - mise - nvm +- corepack - pyenv - rbenv diff --git a/src/main.rs b/src/main.rs index 5d632c6..f243a66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; @@ -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}; @@ -58,6 +60,7 @@ fn all_package_managers() -> Vec> { 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" }), @@ -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) } } diff --git a/src/package_managers/corepack.rs b/src/package_managers/corepack.rs new file mode 100644 index 0000000..4822ccf --- /dev/null +++ b/src/package_managers/corepack.rs @@ -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 { + 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 { + 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)) + } +} diff --git a/tests/e2e/cases/debian.sh b/tests/e2e/cases/debian.sh index dc0fa66..fedf0be 100755 --- a/tests/e2e/cases/debian.sh +++ b/tests/e2e/cases/debian.sh @@ -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 @@ -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 → command not found" +else + echo "FAIL: how did not report 'command not found'" + echo "$out" | sed 's/^/ | /' + fail=$((fail + 1)) +fi + echo if (( fail > 0 )); then echo "$fail assertion(s) failed" diff --git a/tests/e2e/debian.Dockerfile b/tests/e2e/debian.Dockerfile index 6e8f46a..3d246ee 100644 --- a/tests/e2e/debian.Dockerfile +++ b/tests/e2e/debian.Dockerfile @@ -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 -