diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 6506b62ad..9cf22562a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -163,11 +163,11 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **npm hosted-mode `replace-registry-host` contract**: npm >=8 rewrites the origin of a lock's `resolved` URL to the configured registry when its `replace-registry-host` setting is `always`, or equals the URL's hostname (`npmjs`, the default, stands for `registry.npmjs.org`; `never` matches nothing). A hosted pin rewritten that way is fetched from `/patch/npm/…` and every `npm ci` / `npm install` fails E404 (closed: never unpatched bytes; a warm npm cache can hide it locally). Whenever a run leaves a root npm lock carrying a granted hosted artifact URL (the same trigger as the `allow-remote` contract, so an "already on hosted patches" re-run reports it too), the CLI resolves `replace-registry-host` across npm's layers — an `npm_config_replace_registry_host` environment variable (any spelling npm normalizes), then the project `.npmrc`, then the user / global / builtin config files (located exactly like the `allow-remote` layers), its value env-replaced the way npm reads it (`${VAR}` / `${VAR?}`: `replace-registry-host=${RRH}` with `RRH=always` counts as `always`, and the warning quotes both spellings; an unset `${VAR}` stays literal, as in npm) — and, when the effective value rewrites a pinned host, emits the `redirect_npm_replace_registry_host` warning naming the layer (env var / project file / config path) and the remedies: `replace-registry-host=npmjs` in the project `.npmrc` (or unsetting the env var), or vendored mode, whose `file:` resolutions npm never rewrites. The setting is never rewritten; the redirect itself still lands and the exit status is unchanged. -**pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` / `npm_config_shared_workspace_lockfile`; booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. +**pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` on pnpm 11 and newer, `npm_config_shared_workspace_lockfile` on pnpm 10 and older, either when the major is unknown (#1460, the major as for the `lockfile` setting below); booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. -**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads `npm_config_lockfile` / `pnpm_config_lockfile` from its environment. Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. +**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads the setting from its environment, in the one variable family that major reads (#1460): `pnpm_config_lockfile` / `pnpm_config_package_lock` on pnpm 11 and newer, `npm_config_lockfile` / `npm_config_package_lock` on pnpm 10 and older, either family when the major is unknown (so the `npm_config_package_lock=` that `npx` / `npm exec` export from an npm-era `.npmrc` refuses nothing on pnpm 11+). Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. -**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. +**pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `pnpm_config_git_branch_lockfile` (pnpm 11 and newer) or `npm_config_git_branch_lockfile` (pnpm 10 and older; either when the major is unknown, read as for the `lockfile` setting above) set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. `redirect_pnpm_no_lockfile` names pnpm when installer markers exist without a lock; `redirect_pnpm_entry_vendored` identifies a vendored entry instead of reporting it missing. Supported `shrinkwrap.yaml` files are writable lockfiles, not read-only markers. diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index f7c136dee..5cab214f0 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -1064,30 +1064,7 @@ fn scrubbed_cli() -> std::process::Command { /// Run `scan --mode hosted --json` as a subprocess against `cwd` and parse /// the stdout envelope (exit code, parsed JSON). fn run_hosted_json(cwd: &Path, api_url: &str) -> (Option, serde_json::Value) { - let out = scrubbed_cli() - .args([ - "scan", - "--mode", - "hosted", - "--json", - "--yes", - "--cwd", - cwd.to_str().unwrap(), - "--api-url", - api_url, - "--org", - ORG, - "--api-token", - "fake", - ]) - .output() - .expect("run socket-patch"); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - let doc = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("stdout must be the JSON envelope ({e});\nstdout=\n{stdout}\nstderr=\n{stderr}") - }); - (out.status.code(), doc) + run_hosted_json_env(cwd, api_url, &[]) } /// Legacy shrinkwrap projects support both installed discovery and the @@ -2444,6 +2421,144 @@ async fn hosted_scan_refuses_a_git_branch_lockfile_project() { } } +/// [`run_hosted_json`] with pnpm's `lockfile` / `package-lock` / +/// `git-branch-lockfile` environment spellings cleared and then `env` set. +fn run_hosted_json_env( + cwd: &Path, + api_url: &str, + env: &[(&str, &str)], +) -> (Option, serde_json::Value) { + let mut cmd = scrubbed_cli(); + for prefix in ["npm_config_", "pnpm_config_"] { + for key in ["lockfile", "package_lock", "git_branch_lockfile"] { + cmd.env_remove(format!("{prefix}{key}")); + } + } + cmd.envs(env.iter().copied()); + let out = cmd + .args([ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + cwd.to_str().unwrap(), + "--api-url", + api_url, + "--org", + ORG, + "--api-token", + "fake", + ]) + .output() + .expect("run socket-patch"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + let doc = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("stdout must be the JSON envelope ({e});\nstdout=\n{stdout}\nstderr=\n{stderr}") + }); + (out.status.code(), doc) +} + +/// #1460: each pnpm major reads one family of environment variables, +/// `npm_config_*` on pnpm 10 and older and `pnpm_config_*` on pnpm 11 and +/// newer. A setting in the family the project's pnpm ignores (e.g. the +/// `npm_config_package_lock=` that `npx` exports from an npm-era `.npmrc` +/// on a pnpm 12 project) used to refuse with +/// `redirect_pnpm_lockfile_disabled` / `redirect_pnpm_git_branch_lockfile` +/// and leave the lock unpinned; it now pins. The family that major reads +/// still refuses. +#[tokio::test] +#[serial] +async fn hosted_scan_reads_pnpm_env_settings_for_the_project_pnpm_major() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + mock_view(&server).await; + const OFF: &str = "redirect_pnpm_lockfile_disabled"; + const BRANCH: &str = "redirect_pnpm_git_branch_lockfile"; + for (pm, var, value, refusal) in [ + ("pnpm@12.11.2", "npm_config_package_lock", "", None), + ("pnpm@12.11.2", "npm_config_lockfile", "false", None), + ("pnpm@11.28.3", "npm_config_lockfile", "false", None), + ("pnpm@10.34.6", "pnpm_config_lockfile", "false", None), + ("pnpm@9.15.9", "pnpm_config_lockfile", "false", None), + ( + "pnpm@12.11.2", + "npm_config_git_branch_lockfile", + "true", + None, + ), + ( + "pnpm@10.34.6", + "pnpm_config_git_branch_lockfile", + "true", + None, + ), + // Controls: the family the major reads. + ("pnpm@11.28.3", "pnpm_config_lockfile", "false", Some(OFF)), + ("pnpm@10.34.6", "npm_config_lockfile", "false", Some(OFF)), + ( + "pnpm@12.11.2", + "pnpm_config_git_branch_lockfile", + "true", + Some(BRANCH), + ), + ( + "pnpm@10.34.6", + "npm_config_git_branch_lockfile", + "true", + Some(BRANCH), + ), + ] { + let case = format!("{pm} {var}={value}"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write_pnpm_project(root); + std::fs::write( + root.join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "packageManager": "{pm}", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + // A leftover branch lock: live only while the setting is on. + let lock = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + std::fs::write(root.join("pnpm-lock.feature.yaml"), &lock).unwrap(); + + let (code, doc) = run_hosted_json_env(root, &server.uri(), &[(var, value)]); + assert_eq!(code, Some(0), "{case}: {doc}"); + let codes: Vec<&str> = doc["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(|w| w["code"].as_str()) + .collect(); + match refusal { + Some(refusal) => { + assert_eq!(hosted_pin_count(&doc), 0, "{case}: {doc}"); + assert!(codes.contains(&refusal), "{case}: {codes:?}"); + assert!(warning_texts(&doc).contains(var), "{case}: {doc}"); + assert_eq!( + std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(), + lock, + "{case}" + ); + } + None => { + assert_eq!(hosted_pin_count(&doc), 1, "{case}: {doc}"); + assert!( + !codes.contains(&OFF) && !codes.contains(&BRANCH), + "{case}: {codes:?}" + ); + let pinned = std::fs::read_to_string(root.join("pnpm-lock.yaml")).unwrap(); + assert!(pinned.contains(HOSTED_URL), "{case}: {pinned}"); + } + } + } +} + /// #1094: an npm workspace member holding a stray `package-lock.json` / /// `npm-shrinkwrap.json` of its own (one npm never reads; members install /// from the root lock) used to skip the #884 refusal. `scan` and `get` diff --git a/crates/socket-patch-core/src/utils/pnpm_workspace.rs b/crates/socket-patch-core/src/utils/pnpm_workspace.rs index 135b64962..42e2d2d27 100644 --- a/crates/socket-patch-core/src/utils/pnpm_workspace.rs +++ b/crates/socket-patch-core/src/utils/pnpm_workspace.rs @@ -122,13 +122,29 @@ pub fn pnpm_setting( }) } -/// `npmrc_key` from the environment (`pnpm_config_`, then -/// `npm_config_`, dashes as underscores), looked up through `env`: -/// what a CI job sets when neither settings file does. -fn env_setting(npmrc_key: &str, env: impl Fn(&str) -> Option) -> Option { +/// `npmrc_key` from the environment (dashes as underscores), looked up +/// through `env`: what a CI job sets when neither settings file does. +/// +/// Each pnpm major reads one family of variables (#1460): pnpm 11 and +/// newer `pnpm_config_`, pnpm 10 and older `npm_config_`, so the +/// `npm_config_package_lock=` that `npm exec` / `npx` export from an npm-era +/// `.npmrc` means nothing to pnpm 11. With `major` unknown both count, the +/// pnpm spelling first, as for the settings files (see +/// [`lockfile_disabled`]). +fn env_setting( + npmrc_key: &str, + major: Option, + env: impl Fn(&str) -> Option, +) -> Option { let key = npmrc_key.replace('-', "_"); - [format!("pnpm_config_{key}"), format!("npm_config_{key}")] - .into_iter() + let prefixes: &[&str] = match major { + Some(major) if major >= 11 => &["pnpm_config_"], + Some(_) => &["npm_config_"], + None => &["pnpm_config_", "npm_config_"], + }; + prefixes + .iter() + .map(|prefix| format!("{prefix}{key}")) .find_map(|var| { let value = env(&var)?.trim().to_string(); let source = format!("`{var}={value}` in the environment"); @@ -140,21 +156,33 @@ fn env_setting(npmrc_key: &str, env: impl Fn(&str) -> Option) -> Option< }) } -/// [`pnpm_setting`], falling back to the environment ([`env_setting`]) on -/// disk, where the CLI runs pnpm's environment. -fn view_setting( +/// [`pnpm_setting`], falling back to the environment ([`env_setting`], +/// for the project's pnpm major) on disk, where the CLI runs pnpm's +/// environment. +async fn view_setting( view: &ProjectView<'_>, workspace: Option<&str>, npmrc: Option<&str>, yaml_key: &str, npmrc_key: &str, ) -> Option { - pnpm_setting(workspace, npmrc, yaml_key, npmrc_key).or_else(|| { - if matches!(view, ProjectView::Memory(_)) { - return None; - } - env_setting(npmrc_key, |var| std::env::var(var).ok()) - }) + if let Some(found) = pnpm_setting(workspace, npmrc, yaml_key, npmrc_key) { + return Some(found); + } + let env = |var: &str| std::env::var(var).ok(); + if matches!(view, ProjectView::Memory(_)) || !env_any(&[npmrc_key], env) { + return None; + } + let major = pnpm_major(view).await; + env_setting(npmrc_key, major, env) +} + +/// Whether `env` sets any of `npmrc_keys` in either variable family: only +/// then is the pnpm major worth reading for [`env_setting`]. +fn env_any(npmrc_keys: &[&str], env: impl Fn(&str) -> Option) -> bool { + npmrc_keys + .iter() + .any(|key| env_setting(key, None, &env).is_some()) } /// Whether the workspace turned the shared lock off: @@ -376,7 +404,8 @@ async fn workspace_members(view: &ProjectView<'_>) -> Members { npmrc.as_deref(), "sharedWorkspaceLockfile", "shared-workspace-lockfile", - ); + ) + .await; if shared.is_none_or(|setting| setting.as_bool() != Some(false)) { return Members::Shared; } @@ -525,9 +554,10 @@ pub fn is_git_branch_lock_name(name: &str) -> bool { /// project's own `pnpm-workspace.yaml` / `.npmrc`; on disk, a project with /// no `pnpm-workspace.yaml` of its own (a workspace member) also reads the /// governing ancestor's and the `.npmrc` beside it -/// ([`governing_workspace_file`]), and the -/// `npm_config_git_branch_lockfile` / `pnpm_config_git_branch_lockfile` -/// environment spellings count too. +/// ([`governing_workspace_file`]), and the environment spelling the +/// project's pnpm major reads counts too (`pnpm_config_git_branch_lockfile` +/// on pnpm 11+, `npm_config_git_branch_lockfile` on pnpm 10 and older, +/// see [`env_setting`]). pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option { let mut locks = branch_lock_names(view, "").await; let mut members_unlisted = None; @@ -562,8 +592,10 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option }); } } - let setting = - git_branch_on(setting.or_else(|| view_setting(view, None, None, KEYS.0, KEYS.1)))?; + if setting.is_none() { + setting = view_setting(view, None, None, KEYS.0, KEYS.1).await; + } + let setting = git_branch_on(setting)?; Some(GitBranchLocks { setting, locks, @@ -586,25 +618,34 @@ pub async fn git_branch_locks(view: &ProjectView<'_>) -> Option /// the one package.json pins, else what the root lock's grammar implies /// (`pnpm_settings_major`). When none says, either source turning the /// setting off counts: withholding a pin is the safe side. With `env`, on -/// disk, `pnpm_config_lockfile` / `npm_config_lockfile` count too (the -/// hosted engine runs in the install's environment; `vex` need not). +/// disk, the environment counts too, in the variable family that major +/// reads (`pnpm_config_lockfile` on pnpm 11+, `npm_config_lockfile` on +/// pnpm 10 and older, see [`env_setting`]; the hosted engine runs in the +/// install's environment, `vex` need not). /// /// Only the project's own files are read, through the view's /// fingerprinted reader, so a re-scan read cache stays valid; a workspace /// member with no settings file of its own is not related to its root's. pub async fn lockfile_disabled(view: &ProjectView<'_>, env: bool) -> Option { + let env = env.then_some(|var: &str| std::env::var(var).ok()); + lockfile_disabled_in(view, env).await +} + +/// [`lockfile_disabled`], with `env` (when given, on disk) looking the +/// environment up. +async fn lockfile_disabled_in( + view: &ProjectView<'_>, + env: Option Option>, +) -> Option { let workspace = view.read_text(PNPM_WORKSPACE).await.ok(); let npmrc = view.read_text(".npmrc").await.ok(); let yaml = lockfile_off(|key, _| pnpm_setting(workspace.as_deref(), None, key, key)); let rc = lockfile_off(|_, key| pnpm_setting(None, npmrc.as_deref(), key, key)); - let env = match view { - ProjectView::Disk(_) | ProjectView::Snapshot(_) if env => { - lockfile_off(|_, key| env_setting(key, |var| std::env::var(var).ok())) - } - _ => None, - }; - if yaml.is_none() && rc.is_none() { - return env; + let env = env.filter(|env| { + !matches!(view, ProjectView::Memory(_)) && env_any(&["lockfile", "package-lock"], env) + }); + if yaml.is_none() && rc.is_none() && env.is_none() { + return None; } let major = pnpm_major(view).await; match major { @@ -612,7 +653,10 @@ pub async fn lockfile_disabled(view: &ProjectView<'_>, env: bool) -> Option rc, None => yaml.or(rc), } - .or(env) + .or_else(|| { + let env = env?; + lockfile_off(|_, key| env_setting(key, major, &env)) + }) } /// Where one settings source turns pnpm's lock off: its `lockfile` when @@ -770,6 +814,15 @@ mod tests { use super::*; use crate::formats::pnpm::workspace::package_globs; + /// An environment lookup over `vars` alone. + fn env(vars: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |var: &str| { + vars.iter() + .find(|(name, _)| *name == var) + .map(|(_, value)| value.to_string()) + } + } + fn write(root: &Path, rel: &str, text: &str) { let path = root.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -835,15 +888,9 @@ mod tests { #[test] fn the_environment_spells_a_setting_with_underscores() { - let env = |vars: &'static [(&'static str, &'static str)]| { - move |var: &str| { - vars.iter() - .find(|(name, _)| *name == var) - .map(|(_, value)| value.to_string()) - } - }; let found = env_setting( "shared-workspace-lockfile", + None, env(&[("npm_config_shared_workspace_lockfile", " false ")]), ) .unwrap(); @@ -851,9 +898,10 @@ mod tests { assert!(found .source .contains("npm_config_shared_workspace_lockfile")); - // The pnpm spelling wins over npm's. + // With no major known, the pnpm spelling wins over npm's. let found = env_setting( "git-branch-lockfile", + None, env(&[ ("npm_config_git_branch_lockfile", "false"), ("pnpm_config_git_branch_lockfile", "TRUE"), @@ -861,7 +909,96 @@ mod tests { ) .unwrap(); assert_eq!(found.as_bool(), Some(true)); - assert_eq!(env_setting("git-branch-lockfile", env(&[])), None); + assert_eq!(env_setting("git-branch-lockfile", None, env(&[])), None); + } + + /// #1460: pnpm 11 and newer read only `pnpm_config_*`, pnpm 10 and + /// older only `npm_config_*`. + #[test] + fn the_environment_family_follows_the_pnpm_major() { + const NPM: &[(&str, &str)] = &[("npm_config_lockfile", "false")]; + const PNPM: &[(&str, &str)] = &[("pnpm_config_lockfile", "false")]; + for major in [11, 12] { + assert_eq!(env_setting("lockfile", Some(major), env(NPM)), None); + let found = env_setting("lockfile", Some(major), env(PNPM)).unwrap(); + assert!(found.source.contains("pnpm_config_lockfile"), "{major}"); + } + for major in [8, 9, 10] { + assert_eq!(env_setting("lockfile", Some(major), env(PNPM)), None); + let found = env_setting("lockfile", Some(major), env(NPM)).unwrap(); + assert!(found.source.contains("`npm_config_lockfile"), "{major}"); + } + // pnpm 10 ignores the pnpm spelling even beside npm's. + let found = env_setting( + "git-branch-lockfile", + Some(10), + env(&[ + ("npm_config_git_branch_lockfile", "false"), + ("pnpm_config_git_branch_lockfile", "true"), + ]), + ) + .unwrap(); + assert_eq!(found.as_bool(), Some(false)); + } + + /// #1460: the environment turns the lock off only in the variable family + /// the project's pnpm major reads; `npm_config_package_lock=` (what + /// `npm exec` exports from `.npmrc`'s `package-lock=false`) means + /// nothing to pnpm 11+. + #[tokio::test] + async fn the_environment_disables_the_lockfile_only_for_its_pnpm_major() { + const NPM_LOCKFILE: &[(&str, &str)] = &[("npm_config_lockfile", "false")]; + const PNPM_LOCKFILE: &[(&str, &str)] = &[("pnpm_config_lockfile", "false")]; + const NPM_PACKAGE_LOCK: &[(&str, &str)] = &[("npm_config_package_lock", "")]; + for (pm, vars, off) in [ + (Some("pnpm@12.11.2"), NPM_PACKAGE_LOCK, None), + (Some("pnpm@12.11.2"), NPM_LOCKFILE, None), + (Some("pnpm@11.28.3"), NPM_LOCKFILE, None), + (Some("pnpm@10.34.6"), PNPM_LOCKFILE, None), + (Some("pnpm@9.15.9"), PNPM_LOCKFILE, None), + ( + Some("pnpm@11.28.3"), + PNPM_LOCKFILE, + Some("pnpm_config_lockfile"), + ), + ( + Some("pnpm@10.34.6"), + NPM_LOCKFILE, + Some("npm_config_lockfile"), + ), + ( + Some("pnpm@10.34.6"), + NPM_PACKAGE_LOCK, + Some("npm_config_package_lock"), + ), + // No major known: either family counts. + (None, NPM_LOCKFILE, Some("npm_config_lockfile")), + (None, PNPM_LOCKFILE, Some("pnpm_config_lockfile")), + (None, &[], None), + ] { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let manifest = match pm { + Some(pm) => format!(r#"{{"name":"app","packageManager":"{pm}"}}"#), + None => r#"{"name":"app"}"#.to_string(), + }; + write(root, "package.json", &manifest); + write( + root, + PNPM_LOCK, + "lockfileVersion: '9.0' +", + ); + let view = ProjectView::Disk(root); + let found = lockfile_disabled_in(&view, Some(env(vars))).await; + match off { + Some(var) => assert!( + found.as_deref().is_some_and(|source| source.contains(var)), + "{pm:?} {vars:?}: {found:?}" + ), + None => assert_eq!(found, None, "{pm:?} {vars:?}"), + } + } } #[test]