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); +}