feat(cli): port the root command to pacquet (#12632)

* feat(cli): port the `root` command to pacquet

Add `pacquet root`, ported from pnpm11/pnpm/src/cmd/root.ts.

Prints `<dir>/node_modules` built from the canonicalized `--dir`. It hardcodes
the `node_modules` leaf and deliberately does not read `config.modules_dir`:
pnpm's handler ignores a configured modules-dir, and pacquet re-anchors
`config.modules_dir` to the workspace root inside a workspace, whereas pnpm's
`root` uses `config.dir` (the cwd). Anchoring on `--dir` keeps parity in both
cases.

`--global` / `-g` is rejected with a "not supported yet" error: pacquet has no
global packages directory yet, deferred to a shared follow-up that also unblocks
`bin -g`.

Related to pnpm/pnpm#11633.

* fix(cli): gate `root` differential test on Windows and type its `--global` error

The `root_matches_pnpm_from_a_workspace_subdir` differential test spawns the
real `pnpm`, which on Windows is a `pnpm.cmd` shim that `std::process::Command`
cannot resolve via `PATHEXT` ("program not found"). Gate it with
`#[cfg_attr(target_os = "windows", ignore)]`, matching the `cfg(unix)` gating in
`pnpm_compatibility` and `hoist`. The other three `root` tests spawn only
`pacquet` and keep running on Windows.

Replace the bare `miette::miette!` rejection of `pacquet root --global` with a
typed `RootError::GlobalUnsupported` diagnostic (code
`pacquet_cli::root_global_unsupported`), mirroring `runtime`'s `GlobalUnsupported`,
so the refusal carries a stable error code.
This commit is contained in:
Nikita Zhukovskii
2026-06-26 00:29:47 +09:00
committed by GitHub
parent 47685fddab
commit 32d4d3f1f0
3 changed files with 177 additions and 0 deletions

View File

@@ -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::<SilentReporter>(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(())))

View File

@@ -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')`
/// (<https://github.com/pnpm/pnpm/blob/d3f68e2aa4/pnpm11/pnpm/src/cmd/root.ts>):
/// 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 `<dir>/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(())
}
}

View File

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