fix(cli): report why pnpm --version cannot use the pinned pnpm (#14838)
Installing the pinned pnpm and recording the pin both write, and a sandbox with a read-only filesystem has nowhere to write. Printing a version has to work there too, so `pnpm --version` now reports the failure and answers with the running version. The checks that reject a project outright, such as a pin naming another package manager, still fail the command rather than being swallowed along with it. Both stacks go on recording the pin for every command, `--version` included. Only the write's failure is tolerated, not the write itself. `--store-dir` now reaches the pre-command pass through apply_store_dir_override, so a relative or tilde-prefixed value is anchored and gains the store-version directory the rest of the CLI gives it. Fixes https://github.com/pnpm/pnpm/issues/14831 --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
1 parent
eb5ad9268a
commit
8aea95bb06
8 files changed
+384
-46
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"pnpm": patch
|
||||
"pacquet": patch
|
||||
---
|
||||
|
||||
`pnpm --version` now reports why the pnpm version a project pins cannot be installed or recorded, then prints the version of the running CLI. It used to fail, which made the command unusable where the filesystem is read-only. `pnpm --version` also honors `--store-dir` and its `--store` alias now [#14831](https://github.com/pnpm/pnpm/issues/14831).
|
||||
@@ -29,7 +29,7 @@ use super::{
|
||||
use crate::{
|
||||
cli_args::{config_warnings::report_workspace_key_issues, dispatch::seed_config},
|
||||
config_deps,
|
||||
config_overrides::{ConfigOverrides, apply_state_dir_override},
|
||||
config_overrides::{ConfigOverrides, apply_state_dir_override, apply_store_dir_override},
|
||||
engine_pm::{
|
||||
channel::PackageManager,
|
||||
install::{install_engine_from_env, install_engine_to_store},
|
||||
@@ -53,6 +53,7 @@ use pnpm_config::{ColorMode, Config, Host, PNPM_VERSION, PmOnFail};
|
||||
use pnpm_default_reporter::DefaultReporter;
|
||||
use pnpm_env_installer::is_package_manager_resolved;
|
||||
use pnpm_lockfile::{EnvLockfile, LockfileResolution, PackageKey, PackageMetadata, VersionPart};
|
||||
use pnpm_network::redact_and_sanitize;
|
||||
use pnpm_package_manifest::{apply_runtime_on_fail_override, is_runtime_alias};
|
||||
use pnpm_reporter::{GlobalLog, LogEvent, LogLevel, Reporter, SilentReporter};
|
||||
use runtime::{RUNTIME_ON_FAIL_HINT, check_runtimes};
|
||||
@@ -121,10 +122,10 @@ fn pre_command_plan_from_input(
|
||||
if input.switch.command.as_deref().is_some_and(should_skip_command_name) {
|
||||
return Ok(None);
|
||||
}
|
||||
let dir = dunce::canonicalize(&input.switch.dir)
|
||||
let dir = dunce::canonicalize(&input.switch.paths.dir)
|
||||
.into_diagnostic()
|
||||
.wrap_err_with(|| {
|
||||
format!("canonicalizing the `--dir` argument: {}", input.switch.dir.display())
|
||||
format!("canonicalizing the `--dir` argument: {}", input.switch.paths.dir.display())
|
||||
})?;
|
||||
let config = load_pre_command_config(&input.switch, config_overrides, &dir)?;
|
||||
|
||||
@@ -195,7 +196,7 @@ fn load_pre_command_config(
|
||||
config_overrides: &ConfigOverrides,
|
||||
dir: &Path,
|
||||
) -> miette::Result<Config> {
|
||||
let mut config = seed_config(switch.npmrc_auth_file.as_deref(), switch.ignore_workspace)
|
||||
let mut config = seed_config(switch.paths.npmrc_auth_file.as_deref(), switch.ignore_workspace)
|
||||
.current::<Host>(dir)
|
||||
.map_err(miette::Report::new)
|
||||
.wrap_err("load configuration")?;
|
||||
@@ -207,7 +208,10 @@ fn load_pre_command_config(
|
||||
if config.ci {
|
||||
pnpm_default_reporter::force_append_only();
|
||||
}
|
||||
if let Some(state_dir) = switch.state_dir.as_deref() {
|
||||
if let Some(store_dir) = switch.paths.store_dir.as_deref() {
|
||||
apply_store_dir_override::<Host>(&mut config, store_dir, dir)?;
|
||||
}
|
||||
if let Some(state_dir) = switch.paths.state_dir.as_deref() {
|
||||
apply_state_dir_override::<Host>(&mut config, state_dir, dir);
|
||||
}
|
||||
// `--lockfile-dir` moves the lockfile the pin is recorded in, and
|
||||
@@ -249,6 +253,48 @@ fn global_warn(emit: fn(&LogEvent), message: &str) {
|
||||
emit(&LogEvent::Global(GlobalLog { level: LogLevel::Warn, message }));
|
||||
}
|
||||
|
||||
/// Report a pinned pnpm that `pnpm --version` could not act on. Why the
|
||||
/// command carries on afterwards is documented on its caller in `lib.rs`.
|
||||
///
|
||||
/// The command succeeds, so this is a warning rather than a diagnostic
|
||||
/// miette renders. It carries the code and the help a diagnostic came
|
||||
/// with, which is what that rendering would have added.
|
||||
pub(crate) fn warn_pinned_pnpm_unusable(error: &miette::Report) {
|
||||
global_warn(DefaultReporter::emit, &warning_for_unusable_pin(error));
|
||||
}
|
||||
|
||||
fn warning_for_unusable_pin(error: &miette::Report) -> String {
|
||||
let code = error
|
||||
.code()
|
||||
.map(|code| format!("{code}: "))
|
||||
.unwrap_or_default();
|
||||
let help = error
|
||||
.help()
|
||||
.map(|help| format!(". {}", redact_and_sanitize(&help.to_string())))
|
||||
.unwrap_or_default();
|
||||
format!("Cannot use the pnpm version this project pins: {code}{}{help}", error_causes(error))
|
||||
}
|
||||
|
||||
/// Every cause of `error`, in miette's order, dropping the ones an earlier
|
||||
/// cause already quotes — a wrapping error usually renders its source. A
|
||||
/// fetch that failed quotes the registry URL it was given, which carries
|
||||
/// the credentials configured for that registry, so each cause is redacted
|
||||
/// on its way to the terminal.
|
||||
fn error_causes(error: &miette::Report) -> String {
|
||||
let mut causes = String::new();
|
||||
for cause in error.chain() {
|
||||
let cause = redact_and_sanitize(&cause.to_string());
|
||||
if causes.contains(cause.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !causes.is_empty() {
|
||||
causes.push_str(": ");
|
||||
}
|
||||
causes.push_str(&cause);
|
||||
}
|
||||
causes
|
||||
}
|
||||
|
||||
#[derive(Debug, Display, Error, Diagnostic)]
|
||||
pub(crate) enum PreCommandError {
|
||||
#[display("This project is configured to use {name}")]
|
||||
|
||||
@@ -256,10 +256,18 @@ fn env_var_is_false(name: &str) -> bool {
|
||||
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "false" | "0"))
|
||||
}
|
||||
|
||||
pub(super) struct SwitchInput {
|
||||
/// Mirrors [`CliPathArgs`](super::super::cli_command::CliPathArgs) for the
|
||||
/// `--version` path, which scans argv itself: clap answers that flag before
|
||||
/// there is a parsed command line to read the group from.
|
||||
pub(super) struct SwitchPaths {
|
||||
pub(super) dir: PathBuf,
|
||||
pub(super) state_dir: Option<PathBuf>,
|
||||
pub(super) store_dir: Option<PathBuf>,
|
||||
pub(super) npmrc_auth_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(super) struct SwitchInput {
|
||||
pub(super) paths: SwitchPaths,
|
||||
pub(super) command: Option<String>,
|
||||
/// `--frozen-lockfile` / `--no-frozen-lockfile` as typed on the command
|
||||
/// line. `None` leaves the `frozenLockfile` setting to answer.
|
||||
@@ -277,9 +285,12 @@ pub(super) struct SwitchInput {
|
||||
impl SwitchInput {
|
||||
pub(super) fn from_cli_args(args: &CliArgs) -> Self {
|
||||
Self {
|
||||
dir: args.paths.dir.clone(),
|
||||
state_dir: args.paths.state_dir.clone(),
|
||||
npmrc_auth_file: args.paths.npmrc_auth_file.clone(),
|
||||
paths: SwitchPaths {
|
||||
dir: args.paths.dir.clone(),
|
||||
state_dir: args.paths.state_dir.clone(),
|
||||
store_dir: args.paths.store_dir.clone(),
|
||||
npmrc_auth_file: args.paths.npmrc_auth_file.clone(),
|
||||
},
|
||||
command: Some(command_name(&args.command).to_string()),
|
||||
frozen_lockfile: frozen_lockfile_flag(&args.command),
|
||||
pin_flags: PinFlags::of(&args.command),
|
||||
@@ -304,9 +315,12 @@ impl SwitchInput {
|
||||
pub(super) fn from_version_argv(argv: &[OsString]) -> Self {
|
||||
let global_options = ArgTable::top_level(super::super::grammar());
|
||||
let mut input = Self {
|
||||
dir: Self::local_prefix_or_cwd(),
|
||||
state_dir: None,
|
||||
npmrc_auth_file: None,
|
||||
paths: SwitchPaths {
|
||||
dir: Self::local_prefix_or_cwd(),
|
||||
state_dir: None,
|
||||
store_dir: None,
|
||||
npmrc_auth_file: None,
|
||||
},
|
||||
command: None,
|
||||
frozen_lockfile: None,
|
||||
pin_flags: PinFlags::default(),
|
||||
@@ -344,31 +358,48 @@ impl SwitchInput {
|
||||
next: Option<&std::ffi::OsStr>,
|
||||
global_options: &ArgTable,
|
||||
) -> usize {
|
||||
if let Some(value) = short_value(token, "-C", next) {
|
||||
self.dir = PathBuf::from(value);
|
||||
return if token == "-C" { 2 } else { 1 };
|
||||
}
|
||||
if let Some((value, width)) =
|
||||
long_value(token, "dir", next).or_else(|| long_value(token, "prefix", next))
|
||||
{
|
||||
self.dir = PathBuf::from(value);
|
||||
return width;
|
||||
}
|
||||
if let Some((value, width)) = long_value(token, "state-dir", next) {
|
||||
self.state_dir = Some(PathBuf::from(value));
|
||||
if let Some(width) = self.paths.absorb_flag(token, next) {
|
||||
return width;
|
||||
}
|
||||
if let Some(set) = boolean_flag(token, "ignore-workspace") {
|
||||
self.ignore_workspace = set;
|
||||
return 1;
|
||||
}
|
||||
if consumes_next_token(token, global_options) { 2 } else { 1 }
|
||||
}
|
||||
}
|
||||
|
||||
impl SwitchPaths {
|
||||
/// Read one directory flag, returning how many argv tokens it
|
||||
/// consumed, or `None` when the token names none of them.
|
||||
fn absorb_flag(&mut self, token: &str, next: Option<&OsStr>) -> Option<usize> {
|
||||
if let Some(value) = short_value(token, "-C", next) {
|
||||
self.dir = PathBuf::from(value);
|
||||
return Some(if token == "-C" { 2 } else { 1 });
|
||||
}
|
||||
if let Some((value, width)) =
|
||||
long_value(token, "dir", next).or_else(|| long_value(token, "prefix", next))
|
||||
{
|
||||
self.dir = PathBuf::from(value);
|
||||
return Some(width);
|
||||
}
|
||||
if let Some((value, width)) = long_value(token, "state-dir", next) {
|
||||
self.state_dir = Some(PathBuf::from(value));
|
||||
return Some(width);
|
||||
}
|
||||
if let Some((value, width)) =
|
||||
long_value(token, "store-dir", next).or_else(|| long_value(token, "store", next))
|
||||
{
|
||||
self.store_dir = Some(PathBuf::from(value));
|
||||
return Some(width);
|
||||
}
|
||||
if let Some((value, width)) = long_value(token, "npmrc-auth-file", next)
|
||||
.or_else(|| long_value(token, "userconfig", next))
|
||||
{
|
||||
self.npmrc_auth_file = Some(PathBuf::from(value));
|
||||
return width;
|
||||
return Some(width);
|
||||
}
|
||||
if consumes_next_token(token, global_options) { 2 } else { 1 }
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
CliArgs, CliCommand, KeyIssueReporting, PackageManagerToSync, PinRoots, PreCommandInput,
|
||||
PreCommandPlan, SwitchInput, SwitchProcessState, SwitchSource, pre_command_plan_from_input,
|
||||
switch_target,
|
||||
PreCommandPlan, SwitchInput, SwitchProcessState, SwitchSource, load_pre_command_config,
|
||||
pre_command_plan_from_input, switch_target,
|
||||
};
|
||||
use crate::{
|
||||
boolean_negations::with_boolean_negations,
|
||||
cli_args::pre_command::input::{PinFlags, frozen_lockfile_flag},
|
||||
cli_args::pre_command::input::{PinFlags, SwitchPaths, frozen_lockfile_flag},
|
||||
config_overrides::ConfigOverrides,
|
||||
};
|
||||
use clap::{CommandFactory, FromArgMatches};
|
||||
@@ -107,10 +107,10 @@ fn version_argv_reads_dir_auth_file_and_command_forms() {
|
||||
let input = SwitchInput::from_version_argv(&argv);
|
||||
|
||||
if let Some(dir) = case.dir {
|
||||
assert_eq!(input.dir, PathBuf::from(dir), "case: {}", case.name);
|
||||
assert_eq!(input.paths.dir, PathBuf::from(dir), "case: {}", case.name);
|
||||
}
|
||||
assert_eq!(
|
||||
input.npmrc_auth_file,
|
||||
input.paths.npmrc_auth_file,
|
||||
case.npmrc_auth_file.map(PathBuf::from),
|
||||
"case: {}",
|
||||
case.name,
|
||||
@@ -124,7 +124,66 @@ fn version_argv_reads_dir_auth_file_and_command_forms() {
|
||||
OsString::from("/tmp/state"),
|
||||
OsString::from("--version"),
|
||||
]);
|
||||
assert_eq!(input.state_dir.as_deref(), Some(Path::new("/tmp/state")));
|
||||
assert_eq!(input.paths.state_dir.as_deref(), Some(Path::new("/tmp/state")));
|
||||
|
||||
for spelling in ["--store-dir", "--store"] {
|
||||
let input = SwitchInput::from_version_argv(&[
|
||||
OsString::from("pnpm"),
|
||||
OsString::from(spelling),
|
||||
OsString::from("/tmp/store"),
|
||||
OsString::from("--version"),
|
||||
]);
|
||||
assert_eq!(
|
||||
input.paths.store_dir.as_deref(),
|
||||
Some(Path::new("/tmp/store")),
|
||||
"spelling: {spelling}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_warning_carries_the_code_and_help_of_a_diagnostic() {
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, miette::Diagnostic)]
|
||||
#[display("the engine could not be installed")]
|
||||
#[diagnostic(code(ERR_PNPM_TEST), help("Pin an exact version."))]
|
||||
struct Failed;
|
||||
|
||||
let warning = super::warning_for_unusable_pin(&miette::Report::new(Failed));
|
||||
|
||||
assert!(warning.contains("ERR_PNPM_TEST"), "{warning}");
|
||||
assert!(warning.contains("the engine could not be installed"), "{warning}");
|
||||
assert!(warning.contains("Pin an exact version."), "{warning}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reported_causes_redact_registry_credentials() {
|
||||
let error = miette::miette!("fetch https://user:hunter2@registry.example.com/pnpm failed");
|
||||
|
||||
let causes = super::error_causes(&error);
|
||||
|
||||
assert!(!causes.contains("hunter2"), "credentials reached the warning: {causes}");
|
||||
assert!(causes.contains("registry.example.com"), "the host should survive: {causes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pre_command_config_resolves_the_store_dir_flag() {
|
||||
let root = TempDir::new().expect("tmp dir");
|
||||
let dir = dunce::canonicalize(root.path()).expect("canonicalize the project directory");
|
||||
let mut switch = SwitchInput::from_version_argv(&[
|
||||
OsString::from("pnpm"),
|
||||
OsString::from("--store-dir"),
|
||||
OsString::from("relative-store"),
|
||||
OsString::from("--version"),
|
||||
]);
|
||||
switch.paths.dir = dir.clone();
|
||||
|
||||
let config = load_pre_command_config(&switch, &ConfigOverrides::default(), &dir)
|
||||
.expect("load the pre-command config");
|
||||
|
||||
assert_eq!(
|
||||
config.store_dir.root(),
|
||||
dir.join("relative-store").join(pnpm_store_dir::STORE_VERSION),
|
||||
);
|
||||
}
|
||||
|
||||
/// `pnpm --version` still reconciles the `packageManager` pin, so its argv
|
||||
@@ -544,9 +603,12 @@ fn config_overrides(argv: &[&str]) -> ConfigOverrides {
|
||||
fn pre_command_input(dir: &Path) -> PreCommandInput {
|
||||
PreCommandInput {
|
||||
switch: SwitchInput {
|
||||
dir: dir.to_path_buf(),
|
||||
state_dir: None,
|
||||
npmrc_auth_file: None,
|
||||
paths: SwitchPaths {
|
||||
dir: dir.to_path_buf(),
|
||||
state_dir: None,
|
||||
store_dir: None,
|
||||
npmrc_auth_file: None,
|
||||
},
|
||||
command: Some("run".to_string()),
|
||||
frozen_lockfile: None,
|
||||
pin_flags: PinFlags::default(),
|
||||
|
||||
@@ -154,19 +154,40 @@ fn print_version(
|
||||
child_argv: &[OsString],
|
||||
config_overrides: &ConfigOverrides,
|
||||
) -> miette::Result<()> {
|
||||
if let Some(plan) =
|
||||
cli_args::pre_command::pre_command_plan_for_version_flag(argv, config_overrides)?
|
||||
&& block_on_runtime(
|
||||
"pacquet-pre-command",
|
||||
cli_args::pre_command::execute_plan(plan, child_argv),
|
||||
)?
|
||||
{
|
||||
// The version is the command's output, so every warning the checks
|
||||
// below raise belongs on stderr, leaving stdout a bare version string.
|
||||
pnpm_default_reporter::use_stderr();
|
||||
if pinned_pnpm_printed_the_version(argv, child_argv, config_overrides)? {
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", pnpm_config::PNPM_VERSION);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the pinned pnpm answered `--version` for this one. Installing
|
||||
/// that pnpm, and recording the pin, both write, and a sandbox with a
|
||||
/// read-only home has nowhere to write — printing a version has to work
|
||||
/// there too, so the failure is reported and the running version answers.
|
||||
/// The checks themselves still fail the command: a project pinned to
|
||||
/// another package manager is not something a version string can stand in
|
||||
/// for.
|
||||
fn pinned_pnpm_printed_the_version(
|
||||
argv: &[OsString],
|
||||
child_argv: &[OsString],
|
||||
config_overrides: &ConfigOverrides,
|
||||
) -> miette::Result<bool> {
|
||||
let Some(plan) =
|
||||
cli_args::pre_command::pre_command_plan_for_version_flag(argv, config_overrides)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
block_on_runtime("pacquet-pre-command", cli_args::pre_command::execute_plan(plan, child_argv))
|
||||
.or_else(|error| {
|
||||
cli_args::pre_command::warn_pinned_pnpm_unusable(&error);
|
||||
Ok(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pnpm the project pins took the command. When it did, it has
|
||||
/// already run to completion and this process has nothing left to do.
|
||||
fn dispatched_to_pinned_pnpm(
|
||||
|
||||
@@ -236,6 +236,89 @@ fn version_flag_switches_to_the_version_a_range_pin_resolved_to() {
|
||||
drop((root, mock_instance));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn version_flag_reports_a_pin_it_cannot_record() {
|
||||
let CommandTempCwd {
|
||||
pacquet,
|
||||
root,
|
||||
workspace,
|
||||
npmrc_info,
|
||||
..
|
||||
} = CommandTempCwd::init().add_mocked_registry_with_pnpm_version(pnpm_config::PNPM_VERSION);
|
||||
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
|
||||
let pinned = pnpm_config::PNPM_VERSION;
|
||||
fs::write(
|
||||
workspace.join("package.json"),
|
||||
format!(r#"{{"devEngines":{{"packageManager":{{"name":"pnpm","version":"{pinned}"}}}}}}"#),
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
let writable = fs::metadata(&workspace).expect("read the workspace permissions").permissions();
|
||||
let mut read_only = writable.clone();
|
||||
read_only.set_readonly(true);
|
||||
fs::set_permissions(&workspace, read_only).expect("make the workspace read-only");
|
||||
let output = workspace_rejects_writes(&workspace)
|
||||
.then(|| {
|
||||
test_command(pacquet, root.path())
|
||||
.env("PNPM_CONFIG_REGISTRY", mock_instance.url())
|
||||
.args(["--version"])
|
||||
.output()
|
||||
.expect("run pacquet --version")
|
||||
});
|
||||
fs::set_permissions(&workspace, writable).expect("make the workspace writable again");
|
||||
|
||||
let output =
|
||||
output.expect("the read-only bit must reject writes; do not run this test as root");
|
||||
dbg!(&output);
|
||||
assert!(output.status.success(), "pacquet --version should survive a read-only project");
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), format!("{pinned}\n"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("Cannot use the pnpm version this project pins"), "{stderr}");
|
||||
assert!(
|
||||
EnvLockfile::read(&workspace).expect("read the env lockfile").is_none(),
|
||||
"a read-only project cannot have recorded the pin",
|
||||
);
|
||||
|
||||
drop((root, mock_instance));
|
||||
}
|
||||
|
||||
/// Whether the read-only bit set above actually stops a write. It does not
|
||||
/// when the test runs as root, and the case above then has nothing to
|
||||
/// observe, so it fails rather than passing without having run.
|
||||
#[cfg(unix)]
|
||||
fn workspace_rejects_writes(workspace: &Path) -> bool {
|
||||
let probe = workspace.join("write-probe");
|
||||
if fs::write(&probe, "").is_err() {
|
||||
return true;
|
||||
}
|
||||
fs::remove_file(&probe).expect("remove the write probe");
|
||||
false
|
||||
}
|
||||
|
||||
/// Only the steps that write are skipped when the version is all that is
|
||||
/// wanted.
|
||||
#[test]
|
||||
fn version_flag_fails_when_the_project_pins_another_package_manager() {
|
||||
let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init();
|
||||
fs::write(workspace.join("package.json"), r#"{"packageManager":"yarn@4.0.0"}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let output = test_command(pacquet, root.path())
|
||||
.arg("--version")
|
||||
.output()
|
||||
.expect("run pacquet --version");
|
||||
|
||||
dbg!(&output);
|
||||
assert!(!output.status.success(), "a pin naming another package manager must fail");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stderr).contains("This project is configured to use yarn"),
|
||||
"{output:?}",
|
||||
);
|
||||
|
||||
drop(root);
|
||||
}
|
||||
|
||||
fn write_dev_engine_pin(workspace: &Path, version: &str) {
|
||||
fs::write(
|
||||
workspace.join("package.json"),
|
||||
|
||||
+39
-4
@@ -7,13 +7,14 @@ if (!global['pnpm__startedAt']) {
|
||||
global['pnpm__startedAt'] = Date.now()
|
||||
}
|
||||
import path from 'node:path'
|
||||
import { stripVTControlCharacters as stripAnsi } from 'node:util'
|
||||
import { stripVTControlCharacters as stripAnsi, types as utilTypes } from 'node:util'
|
||||
|
||||
import { formatWarn } from '@pnpm/cli.default-reporter'
|
||||
import { isExecutedByCorepack, packageManager } from '@pnpm/cli.meta'
|
||||
import type { Config, ConfigContext } from '@pnpm/config.reader'
|
||||
import { executionTimeLogger, scopeLogger } from '@pnpm/core-loggers'
|
||||
import { getSystemRuntimeVersion } from '@pnpm/engine.runtime.system-version'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { PnpmError, redactAndSanitize } from '@pnpm/error'
|
||||
import { globalWarn, logger } from '@pnpm/logger'
|
||||
import { type EngineDependency, isRuntimeAlias, type RuntimeName } from '@pnpm/types'
|
||||
import { finishWorkers } from '@pnpm/worker'
|
||||
@@ -119,10 +120,13 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
if (context.wantedPackageManager != null) {
|
||||
const pm = context.wantedPackageManager
|
||||
if (pm.onFail !== 'ignore') {
|
||||
const printingVersion = cmd == null && cliOptions.version === true
|
||||
if (pm.name === 'pnpm' && pm.onFail === 'download' && !isExecutedByCorepack()) {
|
||||
// Corepack owns version switching; pnpm only switches versions when
|
||||
// the user is running pnpm directly.
|
||||
await switchCliVersion(config, context)
|
||||
await tolerateWhenPrintingVersion(printingVersion, async () => {
|
||||
await switchCliVersion(config, context)
|
||||
})
|
||||
} else if (cliOptions.global) {
|
||||
globalWarn('Using --global skips the package manager check for this project')
|
||||
} else {
|
||||
@@ -134,7 +138,9 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
// it only writes to the lockfile when the project opted in (via
|
||||
// `devEngines.packageManager`, or a v12+ `packageManager` pin).
|
||||
checkPackageManager(pm, { underCorepack: isExecutedByCorepack() })
|
||||
await syncEnvLockfile(config, context)
|
||||
await tolerateWhenPrintingVersion(printingVersion, async () => {
|
||||
await syncEnvLockfile(config, context)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,6 +424,35 @@ export async function main (inputArgv: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `pnpm --version` must answer even where the pinned pnpm cannot be installed
|
||||
* or recorded: a sandbox with a read-only filesystem leaves pnpm nowhere to
|
||||
* write. The failure is reported and the running pnpm's version is printed
|
||||
* instead of the pinned one. Checks that reject the project outright, like a
|
||||
* pin naming another package manager, still fail the command.
|
||||
*/
|
||||
async function tolerateWhenPrintingVersion (printingVersion: boolean, work: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await work()
|
||||
} catch (err: unknown) {
|
||||
if (!printingVersion) throw err
|
||||
// The version prints before the reporter subscribes to the log stream,
|
||||
// so this warning goes straight to stderr.
|
||||
console.error(formatWarn(`Cannot use the pnpm version this project pins: ${describeFailure(err)}`))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The code and message of `err`, made safe to print. A Node.js filesystem
|
||||
* error opens its message with the code, so naming it again would repeat it.
|
||||
*/
|
||||
function describeFailure (err: unknown): string {
|
||||
if (!utilTypes.isNativeError(err)) return redactAndSanitize(String(err))
|
||||
const code = 'code' in err ? String(err.code) : ''
|
||||
const described = code === '' || err.message.startsWith(code) ? err.message : `${code}: ${err.message}`
|
||||
return redactAndSanitize(described)
|
||||
}
|
||||
|
||||
function printError (message: string, hint?: string): void {
|
||||
const ERROR = chalk.bgRed.red('[') + chalk.bgRed.black('ERROR') + chalk.bgRed.red(']')
|
||||
console.error(`${message.startsWith(ERROR) ? '' : ERROR + ' '}${chalk.red(message)}`)
|
||||
|
||||
@@ -3,10 +3,16 @@ import path from 'node:path'
|
||||
|
||||
import { describe, expect, test } from '@jest/globals'
|
||||
import { prepare, prepareEmpty } from '@pnpm/prepare'
|
||||
import isWindows from 'is-windows'
|
||||
import { writeJsonFileSync } from 'write-json-file'
|
||||
import { writeYamlFileSync } from 'write-yaml-file'
|
||||
|
||||
import { execPnpmSync } from './utils/index.js'
|
||||
|
||||
// The read-only bit on a Windows directory does not stop a file from being
|
||||
// created in it, so the case below has nothing to observe there.
|
||||
const testOnPosix = isWindows() ? test.skip : test
|
||||
|
||||
test('install should fail if the used pnpm version does not satisfy the pnpm version specified in engines', async () => {
|
||||
prepare({
|
||||
name: 'project',
|
||||
@@ -855,3 +861,51 @@ describe('release-brittle: may fail until current version is published to npm',
|
||||
expect(status).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
testOnPosix('pnpm --version reports a pin it cannot record instead of failing', () => {
|
||||
prepare()
|
||||
const projectDir = process.cwd()
|
||||
const pnpmVersion = execPnpmSync(['--version']).stdout.toString().trim()
|
||||
writeJsonFileSync('package.json', {
|
||||
name: 'project',
|
||||
version: '1.0.0',
|
||||
devEngines: {
|
||||
packageManager: {
|
||||
name: 'pnpm',
|
||||
version: pnpmVersion,
|
||||
onFail: 'error',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fs.chmodSync(projectDir, 0o555)
|
||||
let result
|
||||
try {
|
||||
// A test running as root writes through the read-only bit, and this
|
||||
// case then has nothing to observe, so it fails below rather than
|
||||
// passing without having run.
|
||||
if (!canWriteTo(projectDir)) result = execPnpmSync(['--version'])
|
||||
} finally {
|
||||
fs.chmodSync(projectDir, 0o755)
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
throw new Error('the read-only bit must reject writes; do not run this test as root')
|
||||
}
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.toString().trim()).toBe(pnpmVersion)
|
||||
expect(result.stderr.toString()).toContain('Cannot use the pnpm version this project pins')
|
||||
expect(result.stderr.toString()).toContain('permission denied')
|
||||
expect(fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml'))).toBe(false)
|
||||
})
|
||||
|
||||
function canWriteTo (dir: string): boolean {
|
||||
const probe = path.join(dir, 'write-probe')
|
||||
try {
|
||||
fs.writeFileSync(probe, '')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
fs.rmSync(probe)
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user