diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs
index dbf24994d3..e65ec48b0e 100644
--- a/pacquet/crates/cli/src/cli_args.rs
+++ b/pacquet/crates/cli/src/cli_args.rs
@@ -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;
diff --git a/pacquet/crates/cli/src/cli_args/bin.rs b/pacquet/crates/cli/src/cli_args/bin.rs
new file mode 100644
index 0000000000..3a54004240
--- /dev/null
+++ b/pacquet/crates/cli/src/cli_args/bin.rs
@@ -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 `
/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`
+/// ()
+/// and its config-reader bin resolution
+/// ().
+#[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(())
+ }
+}
diff --git a/pacquet/crates/cli/src/cli_args/cli_command.rs b/pacquet/crates/cli/src/cli_args/cli_command.rs
index a0d33b0329..3c5733e33a 100644
--- a/pacquet/crates/cli/src/cli_args/cli_command.rs
+++ b/pacquet/crates/cli/src/cli_args/cli_command.rs
@@ -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.
diff --git a/pacquet/crates/cli/src/cli_args/dispatch.rs b/pacquet/crates/cli/src/cli_args/dispatch.rs
index 4b6ffd2856..1ac7b2a52a 100644
--- a/pacquet/crates/cli/src/cli_args/dispatch.rs
+++ b/pacquet/crates/cli/src/cli_args/dispatch.rs
@@ -254,6 +254,7 @@ fn route<'a>(command: CliCommand, ctx: &RunCtx<'a>) -> miette::Result 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),
diff --git a/pacquet/crates/cli/src/cli_args/dispatch_query.rs b/pacquet/crates/cli/src/cli_args/dispatch_query.rs
index d9f14abde3..97fd1449ed 100644
--- a/pacquet/crates/cli/src/cli_args/dispatch_query.rs
+++ b/pacquet/crates/cli/src/cli_args/dispatch_query.rs
@@ -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(ctx: &RunCtx<'a>, args: BinArgs) -> miette::Result> {
+ 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> {
args.run(ctx.dir)?;
Ok(Box::pin(std::future::ready(Ok(()))))
diff --git a/pacquet/crates/cli/tests/bin.rs b/pacquet/crates/cli/tests/bin.rs
new file mode 100644
index 0000000000..638f2587ae
--- /dev/null
+++ b/pacquet/crates/cli/tests/bin.rs
@@ -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);
+}