From 8aea95bb061973cdf6555cd68bf1a73db9c77bb5 Mon Sep 17 00:00:00 2001 From: Ayush Singh <135635937+Ayush442842q@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:56:51 +0530 Subject: [PATCH] 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 --- .changeset/fix-version-read-only-fs.md | 6 ++ pnpm/crates/cli/src/cli_args/pre_command.rs | 56 +++++++++++-- .../cli/src/cli_args/pre_command/input.rs | 73 +++++++++++----- .../cli/src/cli_args/pre_command/tests.rs | 80 ++++++++++++++++-- pnpm/crates/cli/src/lib.rs | 35 ++++++-- pnpm/crates/cli/tests/suite/version.rs | 83 +++++++++++++++++++ pnpm11/pnpm/src/main.ts | 43 +++++++++- pnpm11/pnpm/test/packageManagerCheck.test.ts | 54 ++++++++++++ 8 files changed, 384 insertions(+), 46 deletions(-) create mode 100644 .changeset/fix-version-read-only-fs.md diff --git a/.changeset/fix-version-read-only-fs.md b/.changeset/fix-version-read-only-fs.md new file mode 100644 index 0000000000..3c55814b9b --- /dev/null +++ b/.changeset/fix-version-read-only-fs.md @@ -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). diff --git a/pnpm/crates/cli/src/cli_args/pre_command.rs b/pnpm/crates/cli/src/cli_args/pre_command.rs index 978241e3c5..70a9d893fe 100644 --- a/pnpm/crates/cli/src/cli_args/pre_command.rs +++ b/pnpm/crates/cli/src/cli_args/pre_command.rs @@ -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 { - 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::(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::(&mut config, store_dir, dir)?; + } + if let Some(state_dir) = switch.paths.state_dir.as_deref() { apply_state_dir_override::(&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}")] diff --git a/pnpm/crates/cli/src/cli_args/pre_command/input.rs b/pnpm/crates/cli/src/cli_args/pre_command/input.rs index 5047a5d7e2..babad9cf89 100644 --- a/pnpm/crates/cli/src/cli_args/pre_command/input.rs +++ b/pnpm/crates/cli/src/cli_args/pre_command/input.rs @@ -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, + pub(super) store_dir: Option, pub(super) npmrc_auth_file: Option, +} + +pub(super) struct SwitchInput { + pub(super) paths: SwitchPaths, pub(super) command: Option, /// `--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 { + 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 } } diff --git a/pnpm/crates/cli/src/cli_args/pre_command/tests.rs b/pnpm/crates/cli/src/cli_args/pre_command/tests.rs index 5f2a8af8bd..f636d08a59 100644 --- a/pnpm/crates/cli/src/cli_args/pre_command/tests.rs +++ b/pnpm/crates/cli/src/cli_args/pre_command/tests.rs @@ -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(), diff --git a/pnpm/crates/cli/src/lib.rs b/pnpm/crates/cli/src/lib.rs index 76e7028e1e..427d4a7fb0 100644 --- a/pnpm/crates/cli/src/lib.rs +++ b/pnpm/crates/cli/src/lib.rs @@ -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 { + 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( diff --git a/pnpm/crates/cli/tests/suite/version.rs b/pnpm/crates/cli/tests/suite/version.rs index bd801160a8..1c295968a1 100644 --- a/pnpm/crates/cli/tests/suite/version.rs +++ b/pnpm/crates/cli/tests/suite/version.rs @@ -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"), diff --git a/pnpm11/pnpm/src/main.ts b/pnpm11/pnpm/src/main.ts index 411fd481c3..b0fc649d0b 100644 --- a/pnpm11/pnpm/src/main.ts +++ b/pnpm11/pnpm/src/main.ts @@ -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 { 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 { // 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 { } } +/** + * `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): Promise { + 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)}`) diff --git a/pnpm11/pnpm/test/packageManagerCheck.test.ts b/pnpm11/pnpm/test/packageManagerCheck.test.ts index 6ab73f69a7..b8909d232b 100644 --- a/pnpm11/pnpm/test/packageManagerCheck.test.ts +++ b/pnpm11/pnpm/test/packageManagerCheck.test.ts @@ -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 +}