diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index 3bf5cb802e..5c73bfe46a 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -26,6 +26,7 @@ pub mod rebuild; pub mod recursive; pub mod remove; pub mod restart; +pub mod root; pub mod run; pub mod runtime; pub mod sanitize; @@ -73,6 +74,7 @@ use prune::PruneArgs; use rebuild::RebuildArgs; use remove::RemoveArgs; use restart::RestartArgs; +use root::RootArgs; use run::RunArgs; use runtime::RuntimeArgs; use serde_json::Value; @@ -227,6 +229,8 @@ pub enum CliCommand { /// Manage runtimes. #[clap(visible_alias = "rt")] Runtime(RuntimeArgs), + /// Print the effective `node_modules` directory. + Root(RootArgs), /// Managing the package store. #[clap(subcommand)] Store(StoreCommand), @@ -754,6 +758,10 @@ impl CliArgs { ReporterType::Silent => Box::pin(args.run::(command_state)), } } + CliCommand::Root(args) => { + args.run(dir_ref)?; + Box::pin(std::future::ready(Ok(()))) + } CliCommand::Store(command) => { command.run(|| config().map(|m| &*m))?; Box::pin(std::future::ready(Ok(()))) diff --git a/pacquet/crates/cli/src/cli_args/root.rs b/pacquet/crates/cli/src/cli_args/root.rs new file mode 100644 index 0000000000..20716ca6ca --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/root.rs @@ -0,0 +1,46 @@ +use clap::Args; +use derive_more::{Display, Error}; +use miette::Diagnostic; +use std::path::Path; + +/// `pacquet root`: print the effective `node_modules` directory. +/// +/// Ports the non-global path of pnpm's `root` handler, which is +/// `path.join(opts.dir, 'node_modules')` +/// (): +/// the leaf is the hardcoded string `node_modules` — a configured +/// `modules-dir` is ignored — and the anchor is `config.dir`, which pnpm +/// sets to the realpath of the CLI directory (the cwd, not the workspace +/// root). So this prints `/node_modules` from the already-canonicalized +/// `--dir` and deliberately does NOT read `config.modules_dir`, which pacquet +/// re-anchors to the workspace root inside a workspace. +#[derive(Debug, Args)] +pub struct RootArgs { + /// Print the global packages directory + #[clap(short = 'g', long)] + pub global: bool, +} + +/// Errors specific to `pacquet root`. +#[derive(Debug, Display, Error, Diagnostic, PartialEq, Eq)] +#[non_exhaustive] +pub enum RootError { + /// `--global` is rejected because the global-dir machinery (pnpm's + /// `@pnpm/global.commands`) is not ported to pacquet yet; refuse rather + /// than print a wrong path. + #[display( + "`pacquet root --global` is not supported yet; global package management has not been ported to pacquet." + )] + #[diagnostic(code(pacquet_cli::root_global_unsupported))] + GlobalUnsupported, +} + +impl RootArgs { + pub fn run(self, dir: &Path) -> miette::Result<()> { + if self.global { + return Err(RootError::GlobalUnsupported.into()); + } + println!("{}", dir.join("node_modules").display()); + Ok(()) + } +} diff --git a/pacquet/crates/cli/tests/root.rs b/pacquet/crates/cli/tests/root.rs new file mode 100644 index 0000000000..06518007be --- /dev/null +++ b/pacquet/crates/cli/tests/root.rs @@ -0,0 +1,123 @@ +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. The CLI runs +/// `dunce::canonicalize` on `--dir`, so the printed path is the resolved +/// form — e.g. on macOS a `/var/folders/...` temp dir surfaces as +/// `/private/var/folders/...`. Mirror that so the expected value matches. +fn canonicalize(path: &Path) -> PathBuf { + dunce::canonicalize(path).expect("canonicalize path") +} + +#[test] +fn root_prints_the_local_node_modules_dir() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + + let output = pacquet.with_args(["root"]).output().expect("run pacquet root"); + dbg!(&output); + assert!(output.status.success(), "pacquet root should succeed"); + + // Deliberately not trimmed, unlike `store path` — pnpm's `root` handler + // emits the path with its trailing newline (`${path}\n`). + let expected = format!("{}\n", canonicalize(&workspace).join("node_modules").display()); + assert_eq!(String::from_utf8_lossy(&output.stdout), expected); + + drop(root); +} + +#[test] +fn root_ignores_a_custom_modules_dir() { + // pnpm's `root` hardcodes the `node_modules` leaf, so a configured + // modules-dir must NOT change its output. pacquet matches by anchoring on + // `--dir` and never reading `config.modules_dir` in this command. + 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(["root"]).output().expect("run pacquet root"); + dbg!(&output); + assert!(output.status.success(), "pacquet root should succeed"); + + let expected = format!("{}\n", canonicalize(&workspace).join("node_modules").display()); + assert_eq!(String::from_utf8_lossy(&output.stdout), expected); + + drop(root); +} + +/// `--global` / `-g` is rejected until global package management is ported. +#[test] +fn root_global_is_not_supported_yet() { + let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init(); + + let output = pacquet.with_args(["root", "-g"]).output().expect("run pacquet root -g"); + dbg!(&output); + assert!(!output.status.success(), "pacquet root -g should fail until global support lands"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not supported yet"), "stderr should explain the gap: {stderr}"); + + drop(root); +} + +/// Differential parity: from a workspace subdirectory pnpm's `root` prints the +/// cwd's `node_modules` (its `config.dir` is the cwd, not the workspace root). +/// pacquet must print byte-identical output. +/// +/// Skipped on Windows, where pnpm is installed as a `pnpm.cmd` shim and +/// `std::process::Command` does not honor `PATHEXT`, so `Command::new("pnpm")` +/// fails with "program not found" (the same reason `pnpm_compatibility.rs` and +/// `hoist.rs` gate their pnpm-spawning tests). The three tests above spawn only +/// `pacquet`, so they keep running on Windows. +#[test] +#[cfg_attr( + target_os = "windows", + ignore = "spawns the external `pnpm` shim (`pnpm.cmd`); std::process::Command can't resolve it via PATHEXT" +)] +fn root_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(["root"]) + .output() + .expect("run pacquet root in the subdir"); + assert!(pacquet_out.status.success(), "pacquet root should succeed in the subdir"); + + let pnpm_out = Command::new("pnpm") + .with_current_dir(&member) + .with_args(["root"]) + .output() + .expect("run pnpm root in the subdir"); + assert!( + pnpm_out.status.success(), + "pnpm root 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 root must match pnpm root from a workspace subdir (cwd, not workspace root)", + ); + + drop(root); +}