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:
Ayush SinghandZoltan Kochan authored and GitHub committed 2026-09-14 13:26:51 +02:00
1 parent eb5ad9268a
commit 8aea95bb06
8 files changed
+384 -46

No files matched your search

+6
View File
@@ -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).
+51 -5
View File
@@ -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(),
+28 -7
View File
@@ -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(
+83
View File
@@ -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
View File
@@ -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
}