mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-14 17:52:44 -04:00
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:
committed by
GitHub
parent
da9f834e34
commit
cc04eb174e
@@ -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;
|
||||
|
||||
43
pacquet/crates/cli/src/cli_args/bin.rs
Normal file
43
pacquet/crates/cli/src/cli_args/bin.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(()))))
|
||||
|
||||
164
pacquet/crates/cli/tests/bin.rs
Normal file
164
pacquet/crates/cli/tests/bin.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user