Files
pnpm/pacquet/crates/cli/tests/exec.rs
Khải 622c056bbc feat(pacquet/cli): initial implementations of run, exec, dlx (#11938)
> [!WARNING]
> **Scope note.** Per [`pacquet/CONTRIBUTING.md`](https://github.com/pnpm/pnpm/blob/d4a2b0364c/pacquet/CONTRIBUTING.md), pacquet's current focus is Stage 1 (the headless installer); `exec` and `dlx` are new top-level commands, so this PR sits outside Stage 1 and is opened for review/discussion ([roadmap pnpm/pacquet#299](https://github.com/pnpm/pacquet/issues/299)).

## Summary

Ports of `run`, `exec`, and `dlx` from the TypeScript pnpm CLI.

- **`run`**: runs scripts through a new foreground `run_script` in `pacquet-executor` (sets up `node_modules/.bin` on `PATH` + the `npm_*` env). Handles `pre`/`post` scripts under `enablePrePostScripts`, arg shell-quoting (with the Windows `cmd /d /s /c` verbatim `raw_arg` path), script listing, hidden (`.`-prefixed) scripts, `--if-present`, the `start`→`server.js` fallback (with the NO_SCRIPT_OR_SERVER guard, including empty-string `start`), the `[ELIFECYCLE]` failure line (with the `test`-stage and signal-killed variants), and exit-code propagation. The recursive runner's scaffolding (`--resume-from` / `--report-summary`) landed separately on `main` via [#12093](https://github.com/pnpm/pnpm/pull/12093); this PR dispatches to it when `-r` is set and hardens it to match pnpm — per-project `pre`/`post`, the `PNPM_SCRIPT_SRC_DIR` recursion guard, pnpm's per-stage no-op guards, hidden-script handling, and `--no-bail`.
- **`exec`**: runs a command with `node_modules/.bin` + `extraBinPaths` on `PATH` (resolved via `which`), stamps `npm_config_user_agent` / `PNPM_PACKAGE_NAME` / `NODE_OPTIONS`, supports `--shell-mode` (the joined command goes through the shared `push_script_arg` helper, so the Windows `cmd /d /s /c` verbatim path uses `raw_arg` and embedded quoting survives), rejects delimiter-containing dirs (`ERR_PNPM_BAD_PATH_DIR`). The **recursive variant** (`-r`) runs the command in every workspace project, topologically sorted and sequential, with `--resume-from` / `--report-summary` / `--no-bail` and pnpm's error codes (`ERR_PNPM_RECURSIVE_EXEC_NO_PACKAGE` / `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL` / `ERR_PNPM_RECURSIVE_FAIL`). The workspace-graph / summary machinery is shared with recursive `run` through a new `cli_args::recursive` module.
- **`dlx`**: installs the package(s) into a TTL cache dir (reusing the install pipeline, anchored at the cache dir, with a *fresh* per-install build-script allow-list — caller's `allow_builds`/`dangerouslyAllowAllBuilds` don't leak in), then runs the resolved bin in the process cwd. Supports `--package`, `--allow-build`, `--shell-mode` (same `push_script_arg` verbatim path as exec), `--cpu`/`--os`/`--libc` architecture overrides (folded into the per-axis `supportedArchitectures` of the dlx install **and** into the cache key, so different overrides don't share a cached install), `dlxCacheMaxAge`; same PATH guard as exec.

New `Config` settings: `enablePrePostScripts`, `scriptShell`, `nodeOptions`, `dlxCacheMaxAge` (wired into `pnpm-workspace.yaml` + the `PNPM_CONFIG_*` overlay). Their defaults match pnpm and are asserted by the `pnpm_default_parity` contract test — this PR moves `enablePrePostScripts` (which pnpm defaults to `true`, a breaking change in [#7634](https://github.com/pnpm/pnpm/pull/7634) shipped in v9) and `dlxCacheMaxAge` into its mapped rows. `extraBinPaths` is kept as a computed field (empty until workspace support lands), matching pnpm — it is not a user-settable key.

## Deferred (documented in code)

- **`--filter` and `--workspace-concurrency`.** Recursive `run` and `exec` run every workspace project sequentially; the `--filter` package-selector subsystem and `--workspace-concurrency` parallelism are not ported yet (the global `--filter` / `--recursive` flags are accepted via clap but `--filter` is not consumed). `dlx` stays single-package by design (matches pnpm).
- `run`: the `/regexp/` script selector and the fuzzy "did you mean" hint are not ported (no regex/levenshtein dep); `scriptsPrependNodePath: always` can't prepend the node dir (pacquet resolves no node execpath anywhere yet).
- `dlx`: the cache key uses raw specs (not resolved ids); no `approve-builds` prompt.
2026-06-02 21:06:11 +02:00

196 lines
7.0 KiB
Rust

use assert_cmd::prelude::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::CommandTempCwd;
#[cfg(unix)]
use std::fs;
#[cfg(unix)]
fn write_executable(path: &std::path::Path, body: &str) {
use std::os::unix::fs::PermissionsExt;
fs::write(path, body).expect("write executable");
let mut perms = fs::metadata(path).expect("stat executable").permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).expect("chmod executable");
}
/// `pacquet exec <command>` resolves the command against the project's
/// `node_modules/.bin` directory and runs it. Mirrors pnpm's exec, which
/// prepends `./node_modules/.bin` to PATH before spawning.
#[cfg(unix)]
#[test]
fn exec_runs_binary_from_node_modules_bin() {
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
let bin_dir = workspace.join("node_modules").join(".bin");
fs::create_dir_all(&bin_dir).expect("create node_modules/.bin");
let marker_path = workspace.join("marker.txt");
write_executable(
&bin_dir.join("say-hi"),
&format!("#!/bin/sh\ntouch \"{}\"\n", marker_path.display()),
);
pacquet.with_arg("exec").with_arg("say-hi").assert().success();
assert!(marker_path.exists(), "the binary in node_modules/.bin should have run");
drop(root);
}
/// Arguments after the command name flow through to the spawned binary.
#[cfg(unix)]
#[test]
fn exec_passes_arguments_to_the_command() {
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
let bin_dir = workspace.join("node_modules").join(".bin");
fs::create_dir_all(&bin_dir).expect("create node_modules/.bin");
let marker_path = workspace.join("args.txt");
write_executable(
&bin_dir.join("write-arg"),
&format!("#!/bin/sh\nprintf %s \"$1\" > \"{}\"\n", marker_path.display()),
);
pacquet.with_arg("exec").with_arg("write-arg").with_arg("hello-world").assert().success();
let written = fs::read_to_string(&marker_path).expect("read marker");
assert_eq!(written, "hello-world");
drop(root);
}
/// `pacquet exec` with no command is an error, mirroring pnpm's
/// `ERR_PNPM_EXEC_MISSING_COMMAND`.
#[test]
fn exec_errors_when_no_command_given() {
let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init();
let output = pacquet.with_arg("exec").output().expect("spawn pacquet exec");
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("STDERR:\n{stderr}\n");
assert!(!output.status.success(), "exec with no command must fail");
assert!(
stderr.contains("requires a command to run"),
"the failure must be the missing-command diagnostic, not an incidental crash",
);
drop(root);
}
/// A command that cannot be resolved against PATH surfaces as a failure,
/// mirroring pnpm's "Command not found" error.
#[test]
fn exec_errors_when_command_not_found() {
let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init();
let output = pacquet
.with_arg("exec")
.with_arg("definitely-not-a-real-binary-xyzzy")
.output()
.expect("spawn pacquet exec");
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("STDERR:\n{stderr}\n");
assert!(!output.status.success(), "a missing command must fail");
assert!(
stderr.contains("definitely-not-a-real-binary-xyzzy") && stderr.contains("not found"),
"the failure must name the missing command, not be an incidental crash",
);
drop(root);
}
/// `--shell-mode` / `-c` runs the command through the platform shell
/// rather than resolving it as a binary.
///
/// Compiles everywhere but is ignored on Windows: the assertion relies on
/// the POSIX `touch` command, which `cmd.exe` does not provide.
#[test]
#[cfg_attr(target_os = "windows", ignore = "relies on the POSIX `touch` command")]
fn exec_shell_mode_runs_shell_command() {
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
let marker_path = workspace.join("shell-marker.txt");
pacquet
.with_arg("exec")
.with_arg("-c")
.with_arg(format!("touch \"{}\"", marker_path.display()))
.assert()
.success();
assert!(marker_path.exists(), "shell-mode command should have run");
drop(root);
}
/// A shell-mode command with embedded quotes reaches the shell untouched.
/// On Windows the default `cmd /d /s /c` path is `windows_verbatim_args`,
/// so the joined command must be appended with `raw_arg`; a plain `arg`
/// would escape the inner quotes and break `node -e "..."`. Runs on every
/// platform but is load-bearing on Windows CI.
#[test]
fn exec_shell_mode_preserves_embedded_quotes() {
let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init();
let output = pacquet
.with_arg("exec")
.with_arg("-c")
.with_arg(r#"node -e "process.stdout.write('shell-quote-ok')""#)
.output()
.expect("spawn pacquet exec");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "shell-mode command must exit 0, got: {output:?}");
assert!(stdout.contains("shell-quote-ok"), "embedded quotes must survive; stdout: {stdout:?}");
drop(root);
}
/// The child's non-zero exit code is propagated as pacquet's own exit
/// code, mirroring pnpm's `{ exitCode }` return.
///
/// Compiles everywhere but is ignored on Windows: shell-mode runs through
/// `cmd.exe` there, and pacquet does not yet honor the verbatim-argument
/// handling that exit-code propagation through `cmd /c` would require.
#[test]
#[cfg_attr(
target_os = "windows",
ignore = "shell-mode exit-code propagation through cmd.exe is not wired up yet"
)]
fn exec_propagates_nonzero_exit_code() {
let CommandTempCwd { pacquet, root, .. } = CommandTempCwd::init();
let output = pacquet
.with_arg("exec")
.with_arg("-c")
.with_arg("exit 3")
.output()
.expect("spawn pacquet exec");
assert_eq!(output.status.code(), Some(3), "the child's exit code must propagate");
drop(root);
}
/// pnpm's `makeEnv` stamps `PNPM_PACKAGE_NAME` from the project's
/// `package.json#name` (makeEnv.ts:30-32). Have the spawned command
/// echo the env var to a marker file and assert it reads back the
/// expected name. Also exercises `read_package_name` end-to-end.
#[cfg(unix)]
#[test]
fn exec_stamps_pnpm_package_name_from_manifest() {
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
let manifest = serde_json::json!({
"name": "@scope/mypkg",
"version": "0.0.0",
})
.to_string();
fs::write(workspace.join("package.json"), manifest).expect("write package.json");
let marker = workspace.join("pkgname.txt");
pacquet
.with_arg("exec")
.with_arg("sh")
.with_arg("-c")
.with_arg(format!(r#"printf %s "$PNPM_PACKAGE_NAME" > "{}""#, marker.display()))
.assert()
.success();
let written = fs::read_to_string(&marker).expect("read marker");
assert_eq!(written, "@scope/mypkg");
drop(root);
}