feat(cli): port the bin command to pacquet (#12703)

Port pnpm's `bin` command, which prints the directory where executables are
installed.

Locally it prints `<dir>/node_modules/.bin`: the `node_modules/.bin` leaf is
hardcoded, so a configured `modules-dir` is ignored, and the path is anchored
on the already-canonicalized `--dir`, mirroring pnpm's config reader.

`--global` resolves `global-bin-dir ?? <pnpm-home>/bin`, creates it, and runs
`check_global_bin_dir` (PATH membership + writability, since
`globalDirShouldAllowWrite` is true for every command except `root`) before
printing — the same `mkdir` + `checkGlobalBinDir` pnpm's config reader performs
for every `--global` command. Errors mirror pnpm:
`ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH`, `ERR_PNPM_NO_PATH_ENV`,
`ERR_PNPM_PNPM_DIR_NOT_WRITABLE`, and `ERR_PNPM_NO_GLOBAL_BIN_DIR`.

Ported from pnpm's bin.ts and config reader.

Related to pnpm/pnpm#11633.
This commit is contained in:
Nikita Zhukovskii
2026-06-30 05:53:32 +09:00
committed by GitHub
parent da9f834e34
commit cc04eb174e
6 changed files with 218 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
pub mod add;
pub mod approve_builds;
pub mod audit;
pub mod bin;
pub mod cache;
pub mod cat_file;
pub mod cat_index;

View File

@@ -0,0 +1,43 @@
use clap::Args;
use pacquet_config::{Config, check_global_bin_dir};
use std::path::Path;
use super::global::GlobalError;
/// `pacquet bin`: print the directory where pnpm installs executables.
///
/// Locally this is `<dir>/node_modules/.bin`: the `node_modules/.bin` leaf is
/// hardcoded, so a configured `modules-dir` is ignored, and the anchor is
/// `--dir` (pnpm's `config.dir`, the cwd, not the workspace root). `--global`
/// prints the resolved global bin directory instead. Ports pnpm's `bin`
/// (<https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/pnpm/src/cmd/bin.ts>)
/// and its config-reader bin resolution
/// (<https://github.com/pnpm/pnpm/blob/3425e8011c/pnpm11/config/reader/src/index.ts>).
#[derive(Debug, Args)]
pub struct BinArgs {
/// Print the global executables directory
#[clap(short = 'g', long)]
pub global: bool,
}
impl BinArgs {
pub fn run(self, dir: &Path, config: &Config) -> miette::Result<()> {
let bin = if self.global {
let bin = config.global_bin.clone().ok_or(GlobalError::NoGlobalBinDir)?;
// Mirror pnpm's config reader: create then validate the global bin
// dir for every `--global` command. `should_allow_write` is true for
// all but `root`, so `bin` checks writability too.
std::fs::create_dir_all(&bin).map_err(|error| {
let bin_dir = bin.display();
miette::miette!("failed to create the global bin directory {bin_dir}: {error}")
})?;
check_global_bin_dir(&bin, std::env::var("PATH").ok().as_deref(), true)
.map_err(miette::Report::new)?;
bin
} else {
dir.join("node_modules").join(".bin")
};
println!("{}", bin.display());
Ok(())
}
}

View File

@@ -2,6 +2,7 @@ use super::{
add::AddArgs,
approve_builds::ApproveBuildsArgs,
audit::AuditArgs,
bin::BinArgs,
cache::CacheCommand,
cat_file::CatFileArgs,
cat_index::CatIndexArgs,
@@ -204,6 +205,8 @@ pub enum CliCommand {
/// Manage runtimes.
#[clap(visible_alias = "rt")]
Runtime(RuntimeArgs),
/// Print the directory where pnpm will install executables.
Bin(BinArgs),
/// Print the effective `node_modules` directory.
Root(RootArgs),
/// Manage the pnpm configuration files.

View File

@@ -254,6 +254,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result<CommandFut
CliCommand::Restart(args) => dispatch_script::restart(ctx, args),
CliCommand::FindHash(args) => dispatch_query::find_hash(ctx, args),
CliCommand::Runtime(args) => dispatch_install::runtime(ctx, args),
CliCommand::Bin(args) => dispatch_query::bin(ctx, args),
CliCommand::Root(args) => dispatch_query::root(ctx, args),
CliCommand::Config(args) => dispatch_query::config(ctx, args),
CliCommand::PackApp(args) => dispatch_query::pack_app(ctx, args),

View File

@@ -1,5 +1,6 @@
use super::{
audit::{AuditArgs, AuditOutcome},
bin::BinArgs,
cache::CacheCommand,
cat_file::CatFileArgs,
cat_index::CatIndexArgs,
@@ -166,6 +167,11 @@ pub(super) fn pack<'a>(ctx: &RunCtx<'a>, args: &PackArgs) -> miette::Result<Comm
Ok(Box::pin(std::future::ready(Ok(()))))
}
pub(super) fn bin<'a>(ctx: &RunCtx<'a>, args: BinArgs) -> miette::Result<CommandFuture<'a>> {
args.run(ctx.dir, (ctx.config)()?)?;
Ok(Box::pin(std::future::ready(Ok(()))))
}
pub(super) fn root<'a>(ctx: &RunCtx<'a>, args: RootArgs) -> miette::Result<CommandFuture<'a>> {
args.run(ctx.dir)?;
Ok(Box::pin(std::future::ready(Ok(()))))

View File

@@ -0,0 +1,164 @@
use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::CommandTempCwd;
use pretty_assertions::assert_eq;
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
/// Canonicalize a path the way the production CLI does (`dunce::canonicalize`
/// on `--dir`), so the expected value matches the resolved form pacquet prints
/// — e.g. on macOS a `/var/folders/...` temp dir surfaces as `/private/var/...`.
fn canonicalize(path: &Path) -> PathBuf {
dunce::canonicalize(path).expect("canonicalize path")
}
#[test]
fn bin_prints_the_local_node_modules_bin_dir() {
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
let output = pacquet.with_args(["bin"]).output().expect("run pacquet bin");
dbg!(&output);
assert!(output.status.success(), "pacquet bin should succeed");
let expected =
format!("{}\n", canonicalize(&workspace).join("node_modules").join(".bin").display());
assert_eq!(String::from_utf8_lossy(&output.stdout), expected);
drop(root);
}
#[test]
fn bin_ignores_a_custom_modules_dir() {
// pnpm hardcodes the `.bin` leaf, so a custom modules-dir is ignored.
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
fs::write(workspace.join("pnpm-workspace.yaml"), "modulesDir: custom_nm\n")
.expect("write pnpm-workspace.yaml");
let output = pacquet.with_args(["bin"]).output().expect("run pacquet bin");
dbg!(&output);
assert!(output.status.success(), "pacquet bin should succeed");
let expected =
format!("{}\n", canonicalize(&workspace).join("node_modules").join(".bin").display());
assert_eq!(String::from_utf8_lossy(&output.stdout), expected);
drop(root);
}
/// `pacquet bin -g` resolves, creates, and (matching pnpm) validates the global
/// bin dir is on `PATH` before printing. The env is pinned so the resolved path
/// is deterministic. Unix-gated like `global.rs`: the `PATH` validation is
/// platform-specific.
#[cfg(unix)]
#[test]
fn bin_global_prints_the_global_bin_dir_when_on_path() {
let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init();
let pnpm_home = root.path().join("pnpm-home");
let global_bin = pnpm_home.join("bin");
let existing_path = std::env::var("PATH").unwrap_or_default();
let path = format!("{}:{existing_path}", global_bin.display());
let output = pacquet
.with_env("PNPM_HOME", &pnpm_home)
.with_env("HOME", root.path())
.with_env("XDG_CONFIG_HOME", root.path().join("xdg-config"))
.with_env("PNPM_CONFIG_GLOBAL_BIN_DIR", "")
.with_env("pnpm_config_global_bin_dir", "")
.with_env("PATH", path)
.with_args(["bin", "-g"])
.output()
.expect("run pacquet bin -g");
dbg!(&output);
assert!(output.status.success(), "pacquet bin -g should succeed when the dir is on PATH");
let expected = format!("{}\n", global_bin.display());
assert_eq!(String::from_utf8_lossy(&output.stdout), expected);
assert!(global_bin.is_dir(), "pacquet bin -g should create the global bin dir");
drop(root);
}
/// `pacquet bin -g` errors like pnpm (`ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH`)
/// when the resolved global bin directory is not on `PATH`. Unix-gated for the
/// same `PATH`-format reason as the success case.
#[cfg(unix)]
#[test]
fn bin_global_errors_when_not_in_path() {
let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init();
let pnpm_home = root.path().join("pnpm-home");
let output = pacquet
.with_env("PNPM_HOME", &pnpm_home)
.with_env("HOME", root.path())
.with_env("XDG_CONFIG_HOME", root.path().join("xdg-config"))
.with_env("PNPM_CONFIG_GLOBAL_BIN_DIR", "")
.with_env("pnpm_config_global_bin_dir", "")
.with_env("PATH", "/usr/bin:/bin")
.with_args(["bin", "-g"])
.output()
.expect("run pacquet bin -g");
dbg!(&output);
assert!(!output.status.success(), "pacquet bin -g should fail when the dir is not on PATH");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("not in PATH") || stderr.contains("ERR_PNPM_GLOBAL_BIN_DIR_NOT_IN_PATH"),
"stderr should explain the global bin dir is not in PATH: {stderr}",
);
drop(root);
}
/// Differential parity: from a workspace subdirectory pnpm's `bin` prints the
/// cwd's `node_modules/.bin` (its `config.dir` is the cwd, not the workspace
/// root). pacquet must match byte-for-byte. Windows-skipped because it spawns
/// the external `pnpm` shim (see the `ignore` reason).
#[test]
#[cfg_attr(
target_os = "windows",
ignore = "spawns the external `pnpm` shim (`pnpm.cmd`); std::process::Command can't resolve it via PATHEXT"
)]
fn bin_matches_pnpm_from_a_workspace_subdir() {
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
fs::write(workspace.join("pnpm-workspace.yaml"), "packages:\n - \"packages/*\"\n")
.expect("write pnpm-workspace.yaml");
fs::write(workspace.join("package.json"), r#"{ "name": "wsroot", "version": "1.0.0" }"#)
.expect("write workspace-root package.json");
let member = workspace.join("packages/foo");
fs::create_dir_all(&member).expect("create workspace member dir");
fs::write(member.join("package.json"), r#"{ "name": "foo", "version": "1.0.0" }"#)
.expect("write member package.json");
let pacquet_out = Command::cargo_bin("pacquet")
.expect("find the pacquet binary")
.with_current_dir(&member)
.with_args(["bin"])
.output()
.expect("run pacquet bin in the subdir");
assert!(pacquet_out.status.success(), "pacquet bin should succeed in the subdir");
let pnpm_out = Command::new("pnpm")
.with_current_dir(&member)
.with_args(["bin"])
.output()
.expect("run pnpm bin in the subdir");
assert!(
pnpm_out.status.success(),
"pnpm bin failed: {}",
String::from_utf8_lossy(&pnpm_out.stderr),
);
let pacquet_stdout = String::from_utf8_lossy(&pacquet_out.stdout);
let pnpm_stdout = String::from_utf8_lossy(&pnpm_out.stdout);
eprintln!("pacquet: {pacquet_stdout:?}\npnpm: {pnpm_stdout:?}");
assert_eq!(
pacquet_stdout, pnpm_stdout,
"pacquet bin must match pnpm bin from a workspace subdir (cwd, not workspace root)",
);
drop(root);
}