diff --git a/pnpm/crates/cli/src/cli_args/exec.rs b/pnpm/crates/cli/src/cli_args/exec.rs index 0979a2186a..72ddee327a 100644 --- a/pnpm/crates/cli/src/cli_args/exec.rs +++ b/pnpm/crates/cli/src/cli_args/exec.rs @@ -178,10 +178,8 @@ pub(super) fn spawn_in_dir( // when no `userAgent` is configured (makeEnv.ts:30). pacquet has // no `userAgent` setting yet, so it always takes that default. cmd.env("npm_config_user_agent", "pnpm"); - // A nested `pnpm run` / `pnpm exec` inside the command must skip - // the verify-deps-before-run check (same stamp as the lifecycle - // env builder; the env var outranks every other config source). - cmd.env("pnpm_config_verify_deps_before_run", "false"); + // Same recursion-guard stamp as the lifecycle env builder. + cmd.env(pacquet_executor::VERIFY_DEPS_BEFORE_RUN_ENV, "false"); if let Some(name) = read_package_name(dir) { cmd.env("PNPM_PACKAGE_NAME", name); } diff --git a/pnpm/crates/cli/src/config_overrides.rs b/pnpm/crates/cli/src/config_overrides.rs index cd6eed7c03..91c1d51740 100644 --- a/pnpm/crates/cli/src/config_overrides.rs +++ b/pnpm/crates/cli/src/config_overrides.rs @@ -182,7 +182,7 @@ impl ConfigOverrides { } fn verify_deps_env_is_set() -> bool { - ["PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN", "pnpm_config_verify_deps_before_run"] + ["PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN", pacquet_executor::VERIFY_DEPS_BEFORE_RUN_ENV] .iter() .any(|name| std::env::var(name).is_ok_and(|value| !value.is_empty())) } diff --git a/pnpm/crates/cli/tests/verify_deps_before_run.rs b/pnpm/crates/cli/tests/verify_deps_before_run.rs index 52ee3efc26..c30d61efc9 100644 --- a/pnpm/crates/cli/tests/verify_deps_before_run.rs +++ b/pnpm/crates/cli/tests/verify_deps_before_run.rs @@ -42,10 +42,6 @@ fn default_install_action_installs_before_running_the_script() { drop(root); } -/// `error` fails a fresh project ("Cannot check whether dependencies -/// are outdated"), passes after an install, still passes after an -/// mtime-only manifest rewrite, and fails again once the manifest no -/// longer matches the lockfile. #[cfg(unix)] #[test] fn error_action_follows_the_dependency_state() { @@ -84,6 +80,27 @@ fn error_action_follows_the_dependency_state() { .assert() .success(); + // Deleting pnpm-lock.yaml in a dependency-less project leaves no + // current lockfile to stand in for it, so the check fails like + // pnpm's RUN_CHECK_DEPS_LOCKFILE_NOT_FOUND — and the pre-run check + // must not recreate the file (pnpm's run path never restores the + // lockfile; only the install command does). + fs::remove_file(workspace.join("pnpm-lock.yaml")).expect("remove pnpm-lock.yaml"); + let output = pacquet_in(&workspace) + .with_args(["--config.verify-deps-before-run=error", "run", "hello"]) + .output() + .expect("spawn pacquet run"); + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("STDERR:\n{stderr}\n"); + assert!(!output.status.success(), "a missing lockfile must fail"); + assert!(stderr.contains("Cannot find a lockfile in"), "expected the lockfile error:\n{stderr}"); + assert!( + !workspace.join("pnpm-lock.yaml").exists(), + "the pre-run check must not write pnpm-lock.yaml", + ); + pacquet_in(&workspace).with_arg("install").assert().success(); + assert!(workspace.join("pnpm-lock.yaml").exists(), "install must restore the lockfile"); + // A manifest that no longer matches the lockfile must fail again. let mut manifest: serde_json::Value = serde_json::from_str( &fs::read_to_string(workspace.join("package.json")).expect("read package.json"), @@ -213,6 +230,49 @@ fn env_var_outranks_the_cli_config_override() { drop(root); } +/// The exec path stamps the same recursion guard as the lifecycle env +/// builder. +#[cfg(unix)] +#[test] +fn exec_children_get_the_check_disabled_through_their_env() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + write_manifest(&workspace, &workspace.join("marker.txt")); + + pacquet + .with_args([ + "--config.verify-deps-before-run=false", + "exec", + "sh", + "-c", + r#"[ "$pnpm_config_verify_deps_before_run" = "false" ]"#, + ]) + .assert() + .success(); + + drop(root); +} + +/// pnpm assigns the `pnpm_config_verify_deps_before_run` env var +/// verbatim, so an unrecognized value is truthy there: the check runs +/// but matches no action, and the script proceeds. +#[cfg(unix)] +#[test] +fn unrecognized_env_value_checks_without_acting() { + let CommandTempCwd { pacquet, root, workspace, .. } = CommandTempCwd::init(); + let marker = workspace.join("marker.txt"); + write_manifest(&workspace, &marker); + + pacquet + .with_env("pnpm_config_verify_deps_before_run", "definitely-not-an-action") + .with_args(["--config.verify-deps-before-run=error", "run", "hello"]) + .assert() + .success(); + assert!(marker.exists(), "the script must run"); + assert!(!workspace.join("node_modules").exists(), "no action may fire"); + + drop(root); +} + /// `pnpm exec` runs the same gate as `pnpm run`. #[cfg(unix)] #[test] diff --git a/pnpm/crates/config/src/env_overlay.rs b/pnpm/crates/config/src/env_overlay.rs index b32fafbe96..5bb7e10e7a 100644 --- a/pnpm/crates/config/src/env_overlay.rs +++ b/pnpm/crates/config/src/env_overlay.rs @@ -202,7 +202,17 @@ impl WorkspaceSettings { json_field!(trust_lockfile, "TRUST_LOCKFILE"); enum_field!(trust_policy, "TRUST_POLICY", TrustPolicy); enum_field!(pm_on_fail, "PM_ON_FAIL", PmOnFail); - enum_field!(verify_deps_before_run, "VERIFY_DEPS_BEFORE_RUN", VerifyDepsBeforeRun); + // pnpm assigns this env var verbatim without validation, so an + // unrecognized value is truthy there: the check runs but matches + // no action. `True` reproduces that, and keeps a present-but- + // invalid value overriding the other config layers the way a + // valid one does. + if let Some(s) = read_env::("VERIFY_DEPS_BEFORE_RUN") { + settings.verify_deps_before_run = Some( + parse_json_or_string::(&s) + .unwrap_or(VerifyDepsBeforeRun::True), + ); + } enum_field!(audit_level, "AUDIT_LEVEL", AuditLevel); json_field!(audit_config, "AUDIT_CONFIG"); json_field!(trust_policy_exclude, "TRUST_POLICY_EXCLUDE"); diff --git a/pnpm/crates/executor/src/lib.rs b/pnpm/crates/executor/src/lib.rs index f4bb36f18c..235d834adf 100644 --- a/pnpm/crates/executor/src/lib.rs +++ b/pnpm/crates/executor/src/lib.rs @@ -9,7 +9,7 @@ pub use lifecycle::{ LifecycleScriptError, PROJECT_LIFECYCLE_STAGES, RunPostinstallHooks, push_script_arg, run_lifecycle_hook, run_postinstall_hooks, run_project_lifecycle_scripts, }; -pub use make_env::{EnvBuild, EnvOptions, build_env}; +pub use make_env::{EnvBuild, EnvOptions, VERIFY_DEPS_BEFORE_RUN_ENV, build_env}; pub use run_script::{RunScript, RunScriptError, run_script}; pub use shell::{ScriptShellError, SelectedShell, select_shell}; diff --git a/pnpm/crates/executor/src/make_env.rs b/pnpm/crates/executor/src/make_env.rs index 073fa5b847..89357ae65b 100644 --- a/pnpm/crates/executor/src/make_env.rs +++ b/pnpm/crates/executor/src/make_env.rs @@ -5,6 +5,12 @@ use std::{ path::{Path, PathBuf}, }; +/// Env var pnpm stamps as `false` into every spawned script and exec +/// child so a nested `pnpm run` / `pnpm exec` skips the +/// verify-deps-before-run check; the config layer reads it back with +/// priority over every other source of that setting (pnpm/pnpm#10060). +pub const VERIFY_DEPS_BEFORE_RUN_ENV: &str = "pnpm_config_verify_deps_before_run"; + /// Inputs needed to build the env for a single lifecycle hook spawn: /// the package context, the per-call stamps the hook runner adds, and /// the caller-supplied `extra_env` overrides. @@ -94,12 +100,9 @@ pub fn build_env( env.insert("npm_config_user_agent".into(), ua.to_string()); } - // A nested `pnpm run` / `pnpm exec` inside a script must skip the - // verify-deps-before-run check — this env var outranks every other - // source of that setting on the config side, breaking the recursion - // a spawned install's lifecycle scripts would otherwise enter - // (pnpm stamps the same value via `config.extraEnv`). - env.insert("pnpm_config_verify_deps_before_run".into(), "false".into()); + // Breaks the recursion a spawned install's lifecycle scripts would + // otherwise enter (pnpm stamps the same value via `config.extraEnv`). + env.insert(VERIFY_DEPS_BEFORE_RUN_ENV.into(), "false".into()); // 4. `extra_env` is applied last among the base env writes, so it // overrides anything stamped above. diff --git a/pnpm/crates/package-manager/src/install.rs b/pnpm/crates/package-manager/src/install.rs index 7498406425..e797b9e1eb 100644 --- a/pnpm/crates/package-manager/src/install.rs +++ b/pnpm/crates/package-manager/src/install.rs @@ -2394,6 +2394,13 @@ pub fn check_deps_status_before_run_at( }, None => None, }; + // pnpm reports "cannot check" straight from the missing workspace + // state, before any project discovery — a fresh project (the common + // out-of-sync case) must not pay for the workspace-projects walk + // only to reach the same verdict inside the check. + if !matches!(pacquet_workspace_state::load_workspace_state(&workspace_root), Ok(Some(_))) { + return cannot_check(); + } let catalogs = match config.catalogs.clone() { Some(catalogs) => catalogs, None => match get_catalogs_from_workspace_manifest(workspace_manifest.as_ref()) { diff --git a/pnpm/crates/package-manager/src/optimistic_repeat_install.rs b/pnpm/crates/package-manager/src/optimistic_repeat_install.rs index f673e4de7b..38003867fc 100644 --- a/pnpm/crates/package-manager/src/optimistic_repeat_install.rs +++ b/pnpm/crates/package-manager/src/optimistic_repeat_install.rs @@ -1469,18 +1469,18 @@ pub fn check_deps_status_before_run(check: &OptimisticRepeatInstallCheck<'_>) -> } if modified.is_empty() && !lockfile_modified { - return match regenerate_wanted_lockfile_if_missing(check, None) { + return match missing_wanted_lockfile_stand_in_ok(check) { Ok(()) => RunDepsStatus::UpToDate, - Err(reason) => outdated(reason.to_string()), + Err(reason) => outdated(reason), }; } let projects_to_check: Vec<&ManifestStat<'_>> = if lockfile_modified { manifest_stats.iter().collect() } else { modified }; match modified_manifests_match_lockfile(check, &state, &projects_to_check) { - Ok(loaded_current) => { - if let Err(reason) = regenerate_wanted_lockfile_if_missing(check, loaded_current) { - return outdated(reason.to_string()); + Ok(_) => { + if let Err(reason) = missing_wanted_lockfile_stand_in_ok(check) { + return outdated(reason); } if is_workspace_install { let mut new_state = crate::install::build_workspace_state( @@ -1512,6 +1512,25 @@ pub fn check_deps_status_before_run(check: &OptimisticRepeatInstallCheck<'_>) -> } } +/// Read-only twin of [`regenerate_wanted_lockfile_if_missing`] for the +/// run gate: pnpm's run-path check never writes `pnpm-lock.yaml` (only +/// the install command restores it from the current lockfile), so a +/// missing wanted lockfile passes exactly when the current lockfile can +/// stand in for it, and the check leaves the workspace untouched. +fn missing_wanted_lockfile_stand_in_ok( + check: &OptimisticRepeatInstallCheck<'_>, +) -> Result<(), String> { + if check.lockfile.is_loaded_or_on_disk() || !check.config.lockfile { + return Ok(()); + } + match Lockfile::load_current_from_virtual_store_dir(&check.config.virtual_store_dir) { + Ok(Some(_)) => Ok(()), + Ok(None) | Err(_) => { + Err(format!("Cannot find a lockfile in {}", check.workspace_root.display())) + } + } +} + /// The `pnpm install` arguments reproducing the dependency groups the /// workspace state recorded, so the `install` / `prompt` actions rerun /// the same kind of install the project last had (pnpm's