From 60789995f134290233be0a4c3dc0f0fcae11935c Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 18 Sep 2026 21:05:34 +0800 Subject: [PATCH 1/8] fix(cli): default unpinned npm to Node's bundled version --- .../command_env_install_no_arg/snapshots.toml | 4 +- ...and_env_install_standalone_npm_fallback.md | 22 ++--- .../npm_bundled_default/assert-npm.cjs | 24 +++++ .../npm_bundled_default/package-lock.json | 5 + .../fixtures/npm_bundled_default/package.json | 7 ++ .../npm_bundled_default/snapshots.toml | 12 +++ .../npm_lockfile_uses_bundled_version.md | 42 ++++++++ .../vp_global_cli/src/commands/env/current.rs | 37 +++++++- .../src/commands/env/package_manager.rs | 17 +++- crates/vp_pm_cli/src/package_manager.rs | 95 +++++-------------- docs/guide/env.md | 4 +- 11 files changed, 180 insertions(+), 89 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/assert-npm.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package-lock.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index 8987a7ce34..af2e35b5b3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -13,7 +13,7 @@ steps = [ [[case]] name = "command_env_install_standalone_npm_fallback" vp = "global" -comment = "Explicit npm family scopes use standalone registry npm; only the directly invoked npm shim keeps Node.js' bundled npm fallback." +comment = "Explicit npm installation uses standalone registry npm without installing Node.js." local-registry = true skip-platforms = ["windows"] seed-runtime = false @@ -21,6 +21,6 @@ env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_SHELL = "bash" } steps = [ { argv = ["vp", "env", "use", "npm", "--no-install"], comment = "an explicit npm scope exports the standalone npm fallback" }, { argv = ["vp", "env", "install", "npm"], comment = "an explicit npm scope installs the standalone registry fallback" }, - { argv = ["vp", "env", "current", "npm", "--json"], comment = "the standalone npm fallback is installed" }, + { argv = ["vp", "env", "list", "npm", "--json"], comment = "the standalone npm fallback is installed" }, { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node", "--assert", "missing"], comment = "installing standalone npm does not install Node.js" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md index 3f1e3d792c..de73d00766 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md @@ -1,6 +1,6 @@ # command_env_install_standalone_npm_fallback -Explicit npm family scopes use standalone registry npm; only the directly invoked npm shim keeps Node.js' bundled npm fallback. +Explicit npm installation uses standalone registry npm without installing Node.js. ## `vp env use npm --no-install` @@ -22,22 +22,20 @@ Installing npm ... Installed npm ``` -## `vp env current npm --json` +## `vp env list npm --json` the standalone npm fallback is installed ``` { - "package_manager": { - "name": "npm", - "version": "", - "source": "registry fallback", - "bin_paths": { - "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", - "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" - }, - "installed": true, - "mode": "managed" + "package_managers": { + "npm": [ + { + "version": "12.0.2", + "current": true, + "default": false + } + ] } } ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/assert-npm.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/assert-npm.cjs new file mode 100644 index 0000000000..b8724fcfc8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/assert-npm.cjs @@ -0,0 +1,24 @@ +const assert = require('node:assert/strict'); +const { execSync } = require('node:child_process'); +const { dirname, resolve } = require('node:path'); + +const bundled = require(resolve( + dirname(process.execPath), + process.platform === 'win32' ? 'node_modules/npm/package.json' : '../lib/node_modules/npm/package.json', +)).version; +const expected = process.argv[2] || bundled; +const run = (command) => execSync(command, { encoding: 'utf8' }).trim(); + +// Clear inherited tool markers so each shim resolves this project's configuration itself. +delete process.env.VP_PATH_INJECTED_TOOLS; +assert.equal(run('npm --version'), expected); +assert.equal(run('npx --version'), expected); +assert.equal(JSON.parse(run('vp pm version --json')).npm, expected); +for (const scope of ['pm', 'npm']) { + const current = JSON.parse(run(`vp env current ${scope} --json`)).package_manager; + assert.equal(current.version, expected); + if (!process.argv[2]) { + assert.equal(current.source, 'Node.js bundled npm'); + } +} +console.log(process.argv[2] ? `npm/npx and vp use configured npm ${expected}` : 'npm/npx and vp use Node bundled npm'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package-lock.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package-lock.json new file mode 100644 index 0000000000..923ed67187 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "npm-bundled-default", + "lockfileVersion": 3, + "packages": {} +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package.json new file mode 100644 index 0000000000..ac20e6d16c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/package.json @@ -0,0 +1,7 @@ +{ + "name": "npm-bundled-default", + "private": true, + "devEngines": { + "runtime": { "name": "node", "version": "22.18.0" } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml new file mode 100644 index 0000000000..bb3643a516 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "npm_lockfile_uses_bundled_version" +vp = "global" +steps = [ + { argv = ["node", "assert-npm.cjs"], comment = "A Node-only devEngines declaration and npm lockfile use bundled npm without querying the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, + { argv = ["vp", "env", "default", "npm@10.5.0"], snapshot = false }, + { argv = ["node", "assert-npm.cjs", "10.5.0"], comment = "An npm default overrides the bundled version even when a lockfile exists" }, + { argv = ["vpt", "json-edit", "package.json", "devEngines.packageManager", "{\"name\":\"npm\",\"version\":\"10.9.4\"}"], snapshot = false }, + { argv = ["node", "assert-npm.cjs", "10.9.4"], comment = "A devEngines npm version overrides the default" }, + { argv = ["vpt", "json-edit", "package.json", "packageManager", "npm@10.8.2"], snapshot = false }, + { argv = ["node", "assert-npm.cjs", "10.8.2"], comment = "A top-level npm pin still has priority" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md new file mode 100644 index 0000000000..31dfbd8cc6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md @@ -0,0 +1,42 @@ +# npm_lockfile_uses_bundled_version + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node assert-npm.cjs` + +A Node-only devEngines declaration and npm lockfile use bundled npm without querying the registry + +``` +npm/npx and vp use Node bundled npm +``` + +## `vp env default npm@10.5.0` + + +## `node assert-npm.cjs 10.5.0` + +An npm default overrides the bundled version even when a lockfile exists + +``` +npm/npx and vp use configured npm +``` + +## `vpt json-edit package.json devEngines.packageManager '{"name":"npm","version":"10.9.4"}'` + + +## `node assert-npm.cjs 10.9.4` + +A devEngines npm version overrides the default + +``` +npm/npx and vp use configured npm +``` + +## `vpt json-edit package.json packageManager npm@10.8.2` + + +## `node assert-npm.cjs 10.8.2` + +A top-level npm pin still has priority + +``` +npm/npx and vp use configured npm +``` diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index a8e99d0237..546e128cfb 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -215,13 +215,48 @@ async fn resolve_package_manager_info( } let resolution = match scope.package_manager() { + Some(vp_pm_cli::PackageManagerType::Npm) => { + package_manager::resolve_shim_for(cwd, vp_pm_cli::PackageManagerType::Npm).await? + } Some(package_manager) => { Some(package_manager::resolve_current_or_fallback_for(cwd, package_manager).await?) } None => package_manager::resolve_current_for(cwd, None).await?, }; let Some(resolution) = resolution else { - return Ok(None); + // A detected npm project without a version pin runs Node's bundled npm. + let bin_dir = super::resolve_node_bin_dir(cwd, config).await?; + let bin_paths = selected_type + .bin_names() + .iter() + .map(|name| { + let path = bin_dir.join(name); + let path = if cfg!(windows) { path.with_extension("cmd") } else { path }; + ((*name).to_string(), path.as_path().display().to_string()) + }) + .collect(); + let manifest = bin_dir.join(if cfg!(windows) { + "node_modules/npm/package.json" + } else { + "../lib/node_modules/npm/package.json" + }); + let version = tokio::fs::read_to_string(&manifest) + .await + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|manifest| manifest["version"].as_str().map(str::to_string)); + return Ok(Some(PackageManagerInfo { + name: selected_type.to_string(), + version: version.clone().unwrap_or_else(|| "unknown".into()), + source: "Node.js bundled npm".into(), + source_path: None, + project_root: selected + .and_then(|resolution| resolution.project_root) + .map(|path| path.as_path().display().to_string()), + installed: version.is_some(), + bin_paths, + mode, + })); }; let package_manager_type = resolution.package_manager_type; let version = resolution.version.to_string(); diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index f7bfa7251b..43ce579ea2 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -76,7 +76,7 @@ pub(crate) async fn resolve_current_for( cwd: &AbsolutePath, expected: Option, ) -> Result, Error> { - let specs = current_specs(expected).await?; + let specs = current_specs(cwd, expected).await?; let mut resolution = resolve_environment_package_manager( cwd, specs.override_spec(), @@ -102,7 +102,7 @@ pub(crate) async fn resolve_current_or_fallback_for( pub(crate) async fn resolve_current_spec( cwd: &AbsolutePath, ) -> Result, Error> { - let specs = current_specs(None).await?; + let specs = current_specs(cwd, None).await?; let mut resolution = resolve_environment_package_manager_spec(cwd, specs.override_spec(), specs.default_spec()) @@ -141,7 +141,10 @@ impl CurrentSpecs { } } -async fn current_specs(expected: Option) -> Result { +async fn current_specs( + cwd: &AbsolutePath, + expected: Option, +) -> Result { let env = vp_shared::EnvConfig::get(); let selected = env .package_manager @@ -151,6 +154,14 @@ async fn current_specs(expected: Option) -> Result Some(expected), + None if selected.is_none() => resolve_environment_package_manager_spec(cwd, None, None)? + .filter(|resolution| resolution.package_manager_type == PackageManagerType::Npm) + .map(|resolution| resolution.package_manager_type), + None => None, + }; let default = expected .map(|package_manager| configured_default_for(&config, package_manager)) .transpose()? diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 5777be5109..37ad278c83 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -228,9 +228,19 @@ impl PackageManagerBuilder { /// Detect the package manager from the current working directory. pub async fn build(&self) -> Result { let (workspace_root, _) = find_workspace_root(&self.cwd)?; - let (package_manager_type, version_or_req, hash, _) = + let (package_manager_type, version_or_req, hash, source) = get_package_manager_type_and_version(&workspace_root, self.client_override)?; + // A lockfile selects npm, but does not request a version separate from Node's npm. + if package_manager_type == PackageManagerType::Npm + && matches!( + source, + PackageManagerSource::LockfileOrConfig | PackageManagerSource::Default + ) + { + return Ok(crate::helpers::default_npm_package_manager(&self.cwd)); + } + // only download the package manager if it's not already downloaded let (install_dir, _package_name, version) = download_package_manager(package_manager_type, &version_or_req, hash.as_deref()) @@ -315,7 +325,7 @@ impl PackageManager { /// from the workspace root. /// /// The returned version is exact when detected from the `packageManager` field, -/// `"latest"` when detected from lockfiles/config files/default, and may be a +/// `"bundled"` for unpinned npm, `"latest"` for other lockfile/config/default selections, and may be a /// semver range (or `"*"` for an absent version) when detected from /// `devEngines.packageManager` (see rfcs/dev-engines.md). pub fn get_package_manager_type_and_version( @@ -367,10 +377,10 @@ pub fn get_package_manager_type_and_version( return Ok((PackageManagerType::Yarn, version, None, source)); } - // if package-lock.json exists, use npm@latest + // A package-lock.json selects npm without requiring a separate installation. let package_lock_json_path = workspace_root.path.join("package-lock.json"); if is_exists_file(&package_lock_json_path)? { - return Ok((PackageManagerType::Npm, version, None, source)); + return Ok((PackageManagerType::Npm, "bundled".into(), None, source)); } // if bun.lock (text format) or bun.lockb (binary format) exists, use bun@latest @@ -409,6 +419,7 @@ pub fn get_package_manager_type_and_version( // if default is specified, use it if let Some(default) = default { + let version = if default == PackageManagerType::Npm { "bundled".into() } else { version }; return Ok((default, version, None, PackageManagerSource::Default)); } @@ -567,6 +578,15 @@ pub async fn resolve_environment_package_manager( .filter(|(package_manager, _, _)| *package_manager == expected) .map(environment_package_manager_default); } + // Let npm shims use the selected Node runtime unless an npm version was configured. + if resolution.as_ref().is_some_and(|resolution| { + resolution.package_manager_type == PackageManagerType::Npm + && resolution.source == PackageManagerSource::LockfileOrConfig.description() + }) { + resolution = default_spec + .filter(|(package_manager, _, _)| *package_manager == PackageManagerType::Npm) + .map(environment_package_manager_default); + } let Some(mut resolution) = resolution else { return Ok(None); }; @@ -2869,71 +2889,6 @@ mod tests { .await; } - #[tokio::test] - #[cfg(not(windows))] // FIXME - async fn test_detect_package_manager_with_package_lock_json() { - let vp_home = shared_vp_home(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_os_str())], - |_| async move { - use std::process::Command; - - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create package-lock.json - fs::write(temp_dir_path.join("package-lock.json"), r#"{"lockfileVersion": 2}"#) - .expect("Failed to write package-lock.json"); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect npm"); - assert_eq!(result.client.to_string(), "npm"); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("npm")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npm.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npm.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx.ps1")).unwrap()); - - // run npm --version - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new("npm") - .arg("--version") - .env("PATH", env::join_paths(&paths).unwrap()) - .output() - .expect("Failed to run npm"); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - // println!("npm --version: {:?}", String::from_utf8_lossy(&output.stdout)); - - // run npx --version - let output = Command::new("npx") - .arg("--version") - .env("PATH", env::join_paths(&paths).unwrap()) - .output() - .expect("Failed to run npx"); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - }, - ) - .await; - } - #[tokio::test] #[cfg(not(windows))] // FIXME async fn test_detect_package_manager_with_package_manager_field() { @@ -4315,7 +4270,7 @@ mod tests { PackageManagerType::Npm, "package-lock.json should take precedence over pnpmfile.cjs and yarn.config.cjs" ); - assert_eq!(version, "latest"); + assert_eq!(version, "bundled"); assert_eq!(hash, None); assert_eq!(source, PackageManagerSource::LockfileOrConfig); } diff --git a/docs/guide/env.md b/docs/guide/env.md index 271e851d68..d10db84cce 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -67,6 +67,8 @@ Package-manager selection uses this priority: 6. The named package manager's global default version 7. The named shim's latest release +For npm, `package-lock.json` selects the package manager but does not select a separate npm version. Without an explicit npm version or a configured npm default, `vp` commands and the npm/npx shims use the npm bundled with the selected Node.js runtime. + `VP_PACKAGE_MANAGER` selects the manager and version for commands such as `vp install`. Direct package-manager shims ignore this variable and use independent version overrides: | Variable | Shims | @@ -89,7 +91,7 @@ VP_PNPM_VERSION=10.20.0 pnpm --version The overrides apply in managed mode. A package manager can also perform its own version switching after Vite+ launches it; for example, pnpm's `managePackageManagerVersions` setting may switch back to the version in `package.json`. -A project selection applies only to its matching shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The directly invoked npm shim keeps its Node-bundled fallback, while an explicit `vp env ... npm` family scope uses standalone npm's latest release. +A project selection applies only to its matching shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The npm shim falls back to Node's bundled npm, which is also reported by `vp env current npm`. Explicit npm installation commands such as `vp env install npm` use standalone npm's latest release when no version is configured. ::: details Latest-version caching When a named shim falls back to the latest release, the resolved version is cached for one hour. An expired cache remains available when the registry cannot be reached. From ad5e9a1a2a4085a4740c215776ed54b7f6766b20 Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 18 Sep 2026 21:44:38 +0800 Subject: [PATCH 2/8] refactor(cli): share package manager default version resolution --- .../vp_global_cli/src/commands/env/current.rs | 2 +- .../src/commands/env/package_manager.rs | 121 ++++++------------ crates/vp_pm_cli/src/package_manager.rs | 60 ++++----- 3 files changed, 64 insertions(+), 119 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 546e128cfb..1ce5c15703 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -189,7 +189,7 @@ async fn resolve_package_manager_info( { let selected = if scope.package_manager().is_some() { // Project provenance is optional when the executable comes from PATH. - vp_pm_cli::resolve_environment_package_manager_spec(cwd, None, None) + vp_pm_cli::resolve_environment_package_manager_spec(cwd, None) .ok() .flatten() .filter(|resolution| resolution.package_manager_type == selected_type) diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index 43ce579ea2..ae7d858c2b 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -30,16 +30,13 @@ pub(crate) async fn resolve_shim_for( let override_spec = version .map(|version| parse_package_manager_spec_with_hash(&format!("{expected}@{version}"))) .transpose()?; - let default = configured_default_for(&config::load_config().await?, expected)?; - let mut resolution = resolve_environment_package_manager( + let resolution = resolve_environment_package_manager_spec( cwd, override_spec .as_ref() .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), - default.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), - Some(expected), - ) - .await?; + )?; + let mut resolution = resolve_selection(resolution, Some(expected)).await?; if override_spec.is_some() && let Some(resolution) = &mut resolution { @@ -76,16 +73,8 @@ pub(crate) async fn resolve_current_for( cwd: &AbsolutePath, expected: Option, ) -> Result, Error> { - let specs = current_specs(cwd, expected).await?; - let mut resolution = resolve_environment_package_manager( - cwd, - specs.override_spec(), - specs.default_spec(), - expected, - ) - .await?; - specs.apply_override_source(&mut resolution); - Ok(resolution) + let resolution = resolve_current_spec(cwd).await?; + resolve_selection(resolution, expected).await } pub(crate) async fn resolve_current_or_fallback_for( @@ -102,49 +91,6 @@ pub(crate) async fn resolve_current_or_fallback_for( pub(crate) async fn resolve_current_spec( cwd: &AbsolutePath, ) -> Result, Error> { - let specs = current_specs(cwd, None).await?; - - let mut resolution = - resolve_environment_package_manager_spec(cwd, specs.override_spec(), specs.default_spec()) - .map_err(Error::from)?; - specs.apply_override_source(&mut resolution); - Ok(resolution) -} - -pub(crate) type PackageManagerSpec = (PackageManagerType, String, Option); - -struct CurrentSpecs { - selected: Option, - default: Option, -} - -impl CurrentSpecs { - fn override_spec(&self) -> Option<(PackageManagerType, &str, Option<&str>)> { - self.selected - .as_ref() - .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())) - } - - fn default_spec(&self) -> Option<(PackageManagerType, &str, Option<&str>)> { - self.default - .as_ref() - .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())) - } - - fn apply_override_source(&self, resolution: &mut Option) { - if self.selected.is_some() - && let Some(resolution) = resolution - { - resolution.source = config::PACKAGE_MANAGER_ENV_VAR.into(); - resolution.source_path = None; - } - } -} - -async fn current_specs( - cwd: &AbsolutePath, - expected: Option, -) -> Result { let env = vp_shared::EnvConfig::get(); let selected = env .package_manager @@ -153,20 +99,36 @@ async fn current_specs( .filter(|spec| !spec.is_empty()) .map(parse_package_manager_spec_with_hash) .transpose()?; + let mut resolution = resolve_environment_package_manager_spec( + cwd, + selected.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), + )?; + if selected.is_some() + && let Some(resolution) = &mut resolution + { + resolution.source = config::PACKAGE_MANAGER_ENV_VAR.into(); + } + Ok(resolution) +} + +pub(crate) type PackageManagerSpec = (PackageManagerType, String, Option); + +/// Both entry points select a family before looking up its default version. +async fn resolve_selection( + resolution: Option, + expected: Option, +) -> Result, Error> { + let kind = + expected.or_else(|| resolution.as_ref().map(|resolution| resolution.package_manager_type)); let config = config::load_config().await?; - // npm lockfiles select the family without overriding its configured default version. - let expected = match expected { - Some(expected) => Some(expected), - None if selected.is_none() => resolve_environment_package_manager_spec(cwd, None, None)? - .filter(|resolution| resolution.package_manager_type == PackageManagerType::Npm) - .map(|resolution| resolution.package_manager_type), - None => None, - }; - let default = expected - .map(|package_manager| configured_default_for(&config, package_manager)) - .transpose()? - .flatten(); - Ok(CurrentSpecs { selected, default }) + let default = kind.map(|kind| configured_default_for(&config, kind)).transpose()?.flatten(); + resolve_environment_package_manager( + resolution, + default.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), + expected, + ) + .await + .map_err(Error::from) } pub(crate) fn configured_default_for( @@ -185,19 +147,8 @@ pub(crate) async fn resolve_from_files_for( cwd: &AbsolutePath, expected: Option, ) -> Result, Error> { - let config = config::load_config().await?; - let default = expected - .map(|package_manager| configured_default_for(&config, package_manager)) - .transpose()? - .flatten(); - resolve_environment_package_manager( - cwd, - None, - default.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), - expected, - ) - .await - .map_err(Error::from) + let resolution = resolve_environment_package_manager_spec(cwd, None)?; + resolve_selection(resolution, expected).await } pub(crate) async fn resolve_from_files_or_fallback_for( diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 37ad278c83..15f2035787 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -476,14 +476,13 @@ pub fn resolve_package_manager_from_package_json( })) } -/// Read the package manager selected by an explicit/session override, project files, or default. +/// Read the package manager selected by an explicit/session override or project files. /// /// The returned version is the declared requirement. It is intentionally not resolved against the /// registry or managed installs, so callers can inspect the selection without network access. pub fn resolve_environment_package_manager_spec( cwd: impl AsRef, override_spec: Option<(PackageManagerType, &str, Option<&str>)>, - default_spec: Option<(PackageManagerType, &str, Option<&str>)>, ) -> Result, Error> { if let Some((package_manager_type, version, hash)) = override_spec { return Ok(Some(EnvironmentPackageManagerResolution { @@ -499,7 +498,7 @@ pub fn resolve_environment_package_manager_spec( let (workspace_root, _) = match find_workspace_root(cwd.as_ref()) { Ok(result) => result, Err(vt_workspace::Error::PackageJsonNotFound(_)) => { - return Ok(default_spec.map(environment_package_manager_default)); + return Ok(None); } Err(error) => return Err(error.into()), }; @@ -540,9 +539,7 @@ pub fn resolve_environment_package_manager_spec( project_root: Some(workspace_root.path.to_absolute_path_buf()), })) } - Err(Error::UnrecognizedPackageManager) => { - Ok(default_spec.map(environment_package_manager_default)) - } + Err(Error::UnrecognizedPackageManager) => Ok(None), Err(error) => Err(error), } } @@ -564,29 +561,23 @@ fn environment_package_manager_default( /// operations such as `vp env install` and package-manager shims. When `expected` is set, a /// different selected family falls back to the matching configured default before registry lookup. pub async fn resolve_environment_package_manager( - cwd: impl AsRef, - override_spec: Option<(PackageManagerType, &str, Option<&str>)>, + resolution: Option, default_spec: Option<(PackageManagerType, &str, Option<&str>)>, expected: Option, ) -> Result, Error> { - let mut resolution = - resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?; - if let Some(expected) = expected - && resolution.as_ref().is_some_and(|resolution| resolution.package_manager_type != expected) - { - resolution = default_spec - .filter(|(package_manager, _, _)| *package_manager == expected) - .map(environment_package_manager_default); - } - // Let npm shims use the selected Node runtime unless an npm version was configured. - if resolution.as_ref().is_some_and(|resolution| { - resolution.package_manager_type == PackageManagerType::Npm - && resolution.source == PackageManagerSource::LockfileOrConfig.description() - }) { - resolution = default_spec - .filter(|(package_manager, _, _)| *package_manager == PackageManagerType::Npm) - .map(environment_package_manager_default); - } + let kind = + expected.or_else(|| resolution.as_ref().map(|resolution| resolution.package_manager_type)); + // A matching project version wins; an npm lockfile only selects the family. + let resolution = resolution.filter(|resolution| { + Some(resolution.package_manager_type) == kind + && !(resolution.package_manager_type == PackageManagerType::Npm + && resolution.source == PackageManagerSource::LockfileOrConfig.description()) + }); + let resolution = resolution.or_else(|| { + default_spec + .filter(|(package_manager, _, _)| kind.is_none_or(|kind| *package_manager == kind)) + .map(environment_package_manager_default) + }); let Some(mut resolution) = resolution else { return Ok(None); }; @@ -2191,9 +2182,13 @@ mod tests { let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); create_package_json(&cwd, r#"{"packageManager":"pnpm@10.18.0"}"#); - let resolution = resolve_environment_package_manager( + let selected = resolve_environment_package_manager_spec( &cwd, Some((PackageManagerType::Yarn, "1.22.22", Some("sha512.example"))), + ) + .unwrap(); + let resolution = resolve_environment_package_manager( + selected, Some((PackageManagerType::Bun, "1.2.0", None)), None, ) @@ -2213,9 +2208,9 @@ mod tests { let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); create_package_json(&cwd, r#"{"name":"example"}"#); + let selected = resolve_environment_package_manager_spec(&cwd, None).unwrap(); let resolution = resolve_environment_package_manager( - &cwd, - None, + selected, Some((PackageManagerType::Bun, "1.2.0", None)), None, ) @@ -2234,9 +2229,9 @@ mod tests { let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); create_package_json(&cwd, r#"{"packageManager":"bun@1.2.0"}"#); + let selected = resolve_environment_package_manager_spec(&cwd, None).unwrap(); let resolution = resolve_environment_package_manager( - &cwd, - None, + selected, Some((PackageManagerType::Pnpm, "10.18.0", None)), Some(PackageManagerType::Pnpm), ) @@ -2258,8 +2253,7 @@ mod tests { r#"{"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}}"#, ); - let resolution = - resolve_environment_package_manager_spec(&cwd, None, None).unwrap().unwrap(); + let resolution = resolve_environment_package_manager_spec(&cwd, None).unwrap().unwrap(); assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); assert_eq!(resolution.version, "^10.0.0"); From 8cd74d32de0768e0e133af870597a3cd934ffbfa Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 18 Sep 2026 22:11:10 +0800 Subject: [PATCH 3/8] test(cli): preserve current npm coverage after standalone install --- .../command_env_install_no_arg/snapshots.toml | 2 ++ ...and_env_install_standalone_npm_fallback.md | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index af2e35b5b3..9ef634e384 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -23,4 +23,6 @@ steps = [ { argv = ["vp", "env", "install", "npm"], comment = "an explicit npm scope installs the standalone registry fallback" }, { argv = ["vp", "env", "list", "npm", "--json"], comment = "the standalone npm fallback is installed" }, { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node", "--assert", "missing"], comment = "installing standalone npm does not install Node.js" }, + { argv = ["vp", "env", "install", "22.18.0"], snapshot = false, timeout = 120000 }, + { argv = ["vp", "env", "current", "npm", "--json"], envs = [["VP_NODE_VERSION", "22.18.0"]], comment = "installing standalone npm does not select it: current reports Node's bundled npm until a version is configured" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md index de73d00766..476f21ccda 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md @@ -47,3 +47,26 @@ installing standalone npm does not install Node.js ``` /.vite-plus/js_runtime/node: missing ``` + +## `vp env install 22.18.0` + + +## `VP_NODE_VERSION=22.18.0 vp env current npm --json` + +installing standalone npm does not select it: current reports Node's bundled npm until a version is configured + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "Node.js bundled npm", + "bin_paths": { + "npm": "/.vite-plus/js_runtime/node//bin/npm", + "npx": "/.vite-plus/js_runtime/node//bin/npx" + }, + "installed": true, + "mode": "managed" + } +} +``` From f60cfb4cb2399964c9da29dc75174c49db8f27e5 Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 18 Sep 2026 22:25:58 +0800 Subject: [PATCH 4/8] wip --- docs/guide/env.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index d10db84cce..c7f676115d 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -65,9 +65,7 @@ Package-manager selection uses this priority: 4. `devEngines.packageManager` 5. Lockfile or manager-specific configuration 6. The named package manager's global default version -7. The named shim's latest release - -For npm, `package-lock.json` selects the package manager but does not select a separate npm version. Without an explicit npm version or a configured npm default, `vp` commands and the npm/npx shims use the npm bundled with the selected Node.js runtime. +7. The named shim's latest release (Node.js' bundled version version for `npm`) `VP_PACKAGE_MANAGER` selects the manager and version for commands such as `vp install`. Direct package-manager shims ignore this variable and use independent version overrides: @@ -91,7 +89,7 @@ VP_PNPM_VERSION=10.20.0 pnpm --version The overrides apply in managed mode. A package manager can also perform its own version switching after Vite+ launches it; for example, pnpm's `managePackageManagerVersions` setting may switch back to the version in `package.json`. -A project selection applies only to its matching shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The npm shim falls back to Node's bundled npm, which is also reported by `vp env current npm`. Explicit npm installation commands such as `vp env install npm` use standalone npm's latest release when no version is configured. +A project selection applies only to its matching shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The npm shim falls back to Node's bundled npm. `vp env use npm` selects standalone npm's latest release for the current shell when no project version or global default is configured. ::: details Latest-version caching When a named shim falls back to the latest release, the resolved version is cached for one hour. An expired cache remains available when the registry cannot be reached. From af0c5493c9544bd159437ddafeb9a886c527972c Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 18 Sep 2026 22:36:39 +0800 Subject: [PATCH 5/8] fix(cli): preserve bundled npm routing and reporting --- .../assert-installed-npm.cjs | 11 +++++++ .../command_env_install_no_arg/snapshots.toml | 2 +- ...and_env_install_standalone_npm_fallback.md | 26 +++++---------- .../npm_bundled_default/snapshots.toml | 2 ++ .../npm_lockfile_uses_bundled_version.md | 16 +++++++++ .../assert-system-node-shim.cjs | 13 ++++++++ .../vp_global_cli/src/commands/env/current.rs | 13 +++++++- crates/vp_global_cli/src/commands/env/list.rs | 6 ++++ crates/vp_global_cli/src/shim/dispatch.rs | 33 +++++++++++-------- crates/vp_pm_cli/src/package_manager.rs | 13 +++++++- 10 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/assert-installed-npm.cjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/assert-installed-npm.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/assert-installed-npm.cjs new file mode 100644 index 0000000000..421650a99e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/assert-installed-npm.cjs @@ -0,0 +1,11 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); + +const installed = JSON.parse( + execFileSync('vp', ['env', 'list', 'npm', '--json'], { encoding: 'utf8' }), +).package_managers.npm; +assert.equal(installed.length, 1); +assert.match(installed[0].version, /^\d+\.\d+\.\d+/); +assert.equal(installed[0].current, false); +assert.equal(installed[0].default, false); +console.log('Standalone npm is installed but is not current'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index 9ef634e384..c4c7bcd9fd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -21,8 +21,8 @@ env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_SHELL = "bash" } steps = [ { argv = ["vp", "env", "use", "npm", "--no-install"], comment = "an explicit npm scope exports the standalone npm fallback" }, { argv = ["vp", "env", "install", "npm"], comment = "an explicit npm scope installs the standalone registry fallback" }, - { argv = ["vp", "env", "list", "npm", "--json"], comment = "the standalone npm fallback is installed" }, { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node", "--assert", "missing"], comment = "installing standalone npm does not install Node.js" }, { argv = ["vp", "env", "install", "22.18.0"], snapshot = false, timeout = 120000 }, + { argv = ["node", "assert-installed-npm.cjs"], envs = [["VP_NODE_VERSION", "22.18.0"]], comment = "the standalone npm fallback is installed but is not selected" }, { argv = ["vp", "env", "current", "npm", "--json"], envs = [["VP_NODE_VERSION", "22.18.0"]], comment = "installing standalone npm does not select it: current reports Node's bundled npm until a version is configured" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md index 476f21ccda..cb6c3c9065 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md @@ -22,24 +22,6 @@ Installing npm ... Installed npm ``` -## `vp env list npm --json` - -the standalone npm fallback is installed - -``` -{ - "package_managers": { - "npm": [ - { - "version": "12.0.2", - "current": true, - "default": false - } - ] - } -} -``` - ## `vpt stat-file $VP_HOME/js_runtime/node --assert missing` installing standalone npm does not install Node.js @@ -51,6 +33,14 @@ installing standalone npm does not install Node.js ## `vp env install 22.18.0` +## `VP_NODE_VERSION=22.18.0 node assert-installed-npm.cjs` + +the standalone npm fallback is installed but is not selected + +``` +Standalone npm is installed but is not current +``` + ## `VP_NODE_VERSION=22.18.0 vp env current npm --json` installing standalone npm does not select it: current reports Node's bundled npm until a version is configured diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml index bb3643a516..1189a19e73 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml @@ -3,6 +3,8 @@ name = "npm_lockfile_uses_bundled_version" vp = "global" steps = [ { argv = ["node", "assert-npm.cjs"], comment = "A Node-only devEngines declaration and npm lockfile use bundled npm without querying the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, + { argv = ["vp", "pm", "patch", "example"], comment = "Bundled npm retains its version gates for unsupported commands" }, + { argv = ["vp", "pm", "approve-builds"], comment = "Bundled npm does not invoke approval commands added in later npm releases" }, { argv = ["vp", "env", "default", "npm@10.5.0"], snapshot = false }, { argv = ["node", "assert-npm.cjs", "10.5.0"], comment = "An npm default overrides the bundled version even when a lockfile exists" }, { argv = ["vpt", "json-edit", "package.json", "devEngines.packageManager", "{\"name\":\"npm\",\"version\":\"10.9.4\"}"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md index 31dfbd8cc6..c1b9467c2f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_lockfile_uses_bundled_version.md @@ -8,6 +8,22 @@ A Node-only devEngines declaration and npm lockfile use bundled npm without quer npm/npx and vp use Node bundled npm ``` +## `vp pm patch example` + +Bundled npm retains its version gates for unsupported commands + +``` +warn: npm does not have a 'patch' command. +``` + +## `vp pm approve-builds` + +Bundled npm does not invoke approval commands added in later npm releases + +``` +warn: npm runs lifecycle scripts by default. Upgrade to npm >= 11.16.0 for `npm approve-scripts`/`deny-scripts`, or set `ignore-scripts=true` in .npmrc and rebuild approved packages with `vp pm rebuild `. +``` + ## `vp env default npm@10.5.0` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/assert-system-node-shim.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/assert-system-node-shim.cjs index ce7b0b83b2..91f356ca20 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/assert-system-node-shim.cjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/assert-system-node-shim.cjs @@ -22,6 +22,19 @@ if (process.argv[2] === 'setup') { execFileSync('node', ['--version'], { encoding: 'utf8', timeout: 10000 }).trim(), process.version, ); + // Current must report the same bundled npm that the external Node shim executes. + const current = JSON.parse( + execFileSync('vp', ['env', 'current', 'npm', '--json'], { + encoding: 'utf8', + timeout: 10000, + }), + ).package_manager; + assert.equal(current.version, '10.9.3'); + assert.equal(current.installed, true); + assert.equal(current.source, 'Node.js bundled npm'); + for (const tool of ['npm', 'npx']) { + assert.equal(current.bin_paths[tool], path.join(path.dirname(process.execPath), tool)); + } const preload = process.argv[2] === 'preload'; const options = { encoding: 'utf8', diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 1ce5c15703..13d85bba2a 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -225,7 +225,18 @@ async fn resolve_package_manager_info( }; let Some(resolution) = resolution else { // A detected npm project without a version pin runs Node's bundled npm. - let bin_dir = super::resolve_node_bin_dir(cwd, config).await?; + let bin_dir = if config.node_shim_mode == ShimMode::SystemFirst + && let Some(node_path) = crate::shim::dispatch::find_system_tool("node") + { + let node_path = crate::shim::dispatch::resolve_external_node_executable(node_path) + .map_err(|error| Error::Other(error.into()))?; + node_path + .parent() + .ok_or_else(|| Error::Other("Node has no bin directory".into()))? + .to_absolute_path_buf() + } else { + super::resolve_node_bin_dir(cwd, config).await? + }; let bin_paths = selected_type .bin_names() .iter() diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 1931a07aad..02488bd727 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -54,6 +54,12 @@ pub async fn execute( }; let current_pm = if scope.includes_package_managers() { match scope.package_manager() { + Some(PackageManagerType::Npm) => { + package_manager::resolve_shim_for(&cwd, PackageManagerType::Npm) + .await + .ok() + .flatten() + } Some(package_manager) => { package_manager::resolve_current_or_fallback_for(&cwd, package_manager).await.ok() } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 40ca462c49..9c954d996b 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1301,19 +1301,7 @@ fn resolve_bundled_tool( return Ok((node_path.clone(), node_path)); } if external_node { - // A version-manager shim may resolve to the manager binary rather than Node. - let output = std::process::Command::new(node_path.as_path()) - .args(["-p", "process.execPath"]) - // User preloads belong to the actual command, not this runtime probe. - .env_remove("NODE_OPTIONS") - .output() - .map_err(|error| format!("Failed to query Node executable: {error}"))?; - if !output.status.success() { - return Err(format!("Failed to query Node executable: {}", output.status)); - } - let executable = String::from_utf8(output.stdout).map_err(|error| error.to_string())?; - node_path = AbsolutePathBuf::new(executable.trim().into()) - .ok_or_else(|| format!("Invalid Node executable path: {}", executable.trim()))?; + node_path = resolve_external_node_executable(node_path)?; } // The resolved directory enters PATH and must also work with Windows .cmd scripts. let node_path = dunce::canonicalize(node_path).map_err(|error| error.to_string())?; @@ -1333,6 +1321,25 @@ fn resolve_bundled_tool( Ok((node_path, tool_path)) } +/// Query external shims for the Node executable they actually select. +pub(crate) fn resolve_external_node_executable( + node_path: AbsolutePathBuf, +) -> Result { + // A version-manager shim may resolve to the manager binary rather than Node. + let output = std::process::Command::new(node_path.as_path()) + .args(["-p", "process.execPath"]) + // User preloads belong to the actual command, not this runtime probe. + .env_remove("NODE_OPTIONS") + .output() + .map_err(|error| format!("Failed to query Node executable: {error}"))?; + if !output.status.success() { + return Err(format!("Failed to query Node executable: {}", output.status)); + } + let executable = String::from_utf8(output.stdout).map_err(|error| error.to_string())?; + AbsolutePathBuf::new(executable.trim().into()) + .ok_or_else(|| format!("Invalid Node executable path: {}", executable.trim())) +} + /// Load shim mode from config. /// /// Returns the default (Managed) if config cannot be read. diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 15f2035787..9984dc460e 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -238,7 +238,18 @@ impl PackageManagerBuilder { PackageManagerSource::LockfileOrConfig | PackageManagerSource::Default ) { - return Ok(crate::helpers::default_npm_package_manager(&self.cwd)); + let mut manager = crate::helpers::default_npm_package_manager(&self.cwd); + // Version gates must describe the npm on PATH, not the latest registry release. + let npm = vp_command::resolve_bin("npm", None, &self.cwd)?; + let output = tokio::process::Command::new(npm.as_path()) + .arg("--version") + .current_dir(&self.cwd) + .output() + .await?; + if output.status.success() { + manager.version = String::from_utf8_lossy(&output.stdout).trim().into(); + } + return Ok(manager); } // only download the package manager if it's not already downloaded From a1f9b65664379524f669b8458fadf2c2dfeca3b1 Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 18 Sep 2026 22:37:32 +0800 Subject: [PATCH 6/8] docs: remove duplicate word in npm fallback guidance --- docs/guide/env.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index c7f676115d..81b060865d 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -65,7 +65,7 @@ Package-manager selection uses this priority: 4. `devEngines.packageManager` 5. Lockfile or manager-specific configuration 6. The named package manager's global default version -7. The named shim's latest release (Node.js' bundled version version for `npm`) +7. The named shim's latest release (Node.js' bundled version for `npm`) `VP_PACKAGE_MANAGER` selects the manager and version for commands such as `vp install`. Direct package-manager shims ignore this variable and use independent version overrides: From 0ed7d7d6ab3555a94806f73ac627ad5af65caad2 Mon Sep 17 00:00:00 2001 From: Liang Date: Sat, 19 Sep 2026 10:06:20 +0800 Subject: [PATCH 7/8] fix(cli): address bundled npm review regressions --- .../npm_bundled_default/snapshots.toml | 42 +++++++++++++ .../snapshots/npm_bundled_env_print.md | 34 +++++++++++ .../npm_bundled_ignores_project_bin.md | 18 ++++++ ..._bundled_version_probe_ignores_preloads.md | 59 +++++++++++++++++++ .../npm_current_does_not_install_node.md | 57 ++++++++++++++++++ .../vp_global_cli/src/commands/env/current.rs | 10 +++- crates/vp_global_cli/src/commands/env/mod.rs | 11 ++++ crates/vp_pm_cli/src/package_manager.rs | 18 ++++-- 8 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_env_print.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_ignores_project_bin.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_version_probe_ignores_preloads.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_current_does_not_install_node.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml index 1189a19e73..093dd7a116 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots.toml @@ -12,3 +12,45 @@ steps = [ { argv = ["vpt", "json-edit", "package.json", "packageManager", "npm@10.8.2"], snapshot = false }, { argv = ["node", "assert-npm.cjs", "10.8.2"], comment = "A top-level npm pin still has priority" }, ] + +[[case]] +name = "npm_current_does_not_install_node" +vp = "global" +seed-runtime = false +env = { VP_NODE_VERSION = "22.18.0", VP_NODE_DIST_MIRROR = "http://127.0.0.1:9" } +steps = [ + { argv = ["vp", "env", "current", "--json"], comment = "An absent Node runtime and bundled npm are reported without downloading" }, + { argv = ["vp", "env", "current", "npm", "--json"], comment = "The explicit npm scope also reports an unknown, uninstalled version" }, + { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node/22.18.0", "--assert", "missing"], comment = "Reporting the environment leaves the runtime uninstalled" }, +] + +[[case]] +name = "npm_bundled_ignores_project_bin" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "bin/npm", "#!/bin/sh\necho project npm must not run\nexit 1\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "bin/npm"], snapshot = false }, + { argv = ["node", "assert-npm.cjs"], comment = "The version probe and actual command both use bundled npm despite an executable project bin/npm" }, +] + +[[case]] +name = "npm_bundled_env_print" +vp = "global" +env = { VP_SHELL = "bash", NPM_CONFIG_REGISTRY = "http://127.0.0.1:9" } +steps = [ + { argv = ["vp", "env", "print", "pm"], comment = "The selected package manager uses Node's bundled npm directory" }, + { argv = ["vp", "env", "print", "npm"], comment = "The explicit npm scope uses the same bundled directory" }, + { argv = ["vp", "env", "print"], comment = "The combined scope includes the shared directory only once" }, +] + +[[case]] +name = "npm_bundled_version_probe_ignores_preloads" +vp = "global" +env = { NODE_OPTIONS = "--require ./preload.cjs" } +steps = [ + { argv = ["vpt", "write-file", "preload.cjs", "console.log('preload start');\n"], snapshot = false }, + { argv = ["vp", "pm", "patch", "example"], comment = "A noisy preload cannot bypass npm's patch version gate" }, + { argv = ["vp", "pm", "approve-builds"], comment = "A noisy preload cannot bypass npm's approval version gate" }, + { argv = ["vp", "pm", "version", "--json"], comment = "The actual npm command still executes the user's preload" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_env_print.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_env_print.md new file mode 100644 index 0000000000..efbb217d5a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_env_print.md @@ -0,0 +1,34 @@ +# npm_bundled_env_print + +## `vp env print pm` + +The selected package manager uses Node's bundled npm directory + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/.vite-plus/js_runtime/node//bin:$PATH" +``` + +## `vp env print npm` + +The explicit npm scope uses the same bundled directory + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/.vite-plus/js_runtime/node//bin:$PATH" +``` + +## `vp env print` + +The combined scope includes the shared directory only once + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/.vite-plus/js_runtime/node//bin:$PATH" +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_ignores_project_bin.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_ignores_project_bin.md new file mode 100644 index 0000000000..0318cf8cd1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_ignores_project_bin.md @@ -0,0 +1,18 @@ +# npm_bundled_ignores_project_bin + +## `vpt write-file bin/npm '#'\!'/bin/sh +echo project npm must not run +exit 1 +'` + + +## `vpt chmod +x bin/npm` + + +## `node assert-npm.cjs` + +The version probe and actual command both use bundled npm despite an executable project bin/npm + +``` +npm/npx and vp use Node bundled npm +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_version_probe_ignores_preloads.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_version_probe_ignores_preloads.md new file mode 100644 index 0000000000..674c2cb3ea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_bundled_version_probe_ignores_preloads.md @@ -0,0 +1,59 @@ +# npm_bundled_version_probe_ignores_preloads + +## `vpt write-file preload.cjs 'console.log('\''preload start'\''); +'` + + +## `vp pm patch example` + +A noisy preload cannot bypass npm's patch version gate + +``` +warn: npm does not have a 'patch' command. +``` + +## `vp pm approve-builds` + +A noisy preload cannot bypass npm's approval version gate + +``` +warn: npm runs lifecycle scripts by default. Upgrade to npm >= 11.16.0 for `npm approve-scripts`/`deny-scripts`, or set `ignore-scripts=true` in .npmrc and rebuild approved packages with `vp pm rebuild `. +``` + +## `vp pm version --json` + +The actual npm command still executes the user's preload + +``` +preload start +{ + "npm": "10.9.3", + "node": "22.18.0", + "acorn": "8.15.0", + "ada": "2.9.2", + "amaro": "1.1.0", + "ares": "1.34.5", + "brotli": "1.1.0", + "cjs_module_lexer": "2.1.0", + "cldr": "47.0", + "icu": "77.1", + "llhttp": "9.3.0", + "modules": "127", + "napi": "10", + "nbytes": "0.1.1", + "ncrypto": "0.0.1", + "nghttp2": "1.64.0", + "openssl": "3.0.16", + "simdjson": "3.13.0", + "simdutf": "6.4.2", + "sqlite": "3.50.2", + "tz": "2025b", + "undici": "6.21.2", + "unicode": "16.0", + "uv": "1.51.0", + "uvwasi": "0.0.21", + "v8": "12.4.254.21-node.27", + "zlib": "1.3.1-470d3a2", + "zstd": "1.5.7" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_current_does_not_install_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_current_does_not_install_node.md new file mode 100644 index 0000000000..b1513a6562 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/npm_bundled_default/snapshots/npm_current_does_not_install_node.md @@ -0,0 +1,57 @@ +# npm_current_does_not_install_node + +## `vp env current --json` + +An absent Node runtime and bundled npm are reported without downloading + +``` +{ + "node": { + "version": "22.18.0", + "source": "VP_NODE_VERSION", + "bin_path": "/.vite-plus/js_runtime/node//bin/node", + "installed": false, + "mode": "managed" + }, + "package_manager": { + "name": "npm", + "version": "unknown", + "source": "Node.js bundled npm", + "project_root": "", + "bin_paths": { + "npm": "/.vite-plus/js_runtime/node//bin/npm", + "npx": "/.vite-plus/js_runtime/node//bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vp env current npm --json` + +The explicit npm scope also reports an unknown, uninstalled version + +``` +{ + "package_manager": { + "name": "npm", + "version": "unknown", + "source": "Node.js bundled npm", + "bin_paths": { + "npm": "/.vite-plus/js_runtime/node//bin/npm", + "npx": "/.vite-plus/js_runtime/node//bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vpt stat-file $VP_HOME/js_runtime/node/22.18.0 --assert missing` + +Reporting the environment leaves the runtime uninstalled + +``` +/.vite-plus/js_runtime/node/: missing +``` diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 13d85bba2a..9d671ee639 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -235,7 +235,15 @@ async fn resolve_package_manager_info( .ok_or_else(|| Error::Other("Node has no bin directory".into()))? .to_absolute_path_buf() } else { - super::resolve_node_bin_dir(cwd, config).await? + // Inspect the selected runtime without installing it just to report its state. + let resolution = resolve_version(cwd).await?; + let home = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolution.version); + if cfg!(windows) { home } else { home.join("bin") } }; let bin_paths = selected_type .bin_names() diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 82778a3ed8..3f568cc799 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -179,6 +179,10 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result { + package_manager::resolve_shim_for(&cwd, vp_pm_cli::PackageManagerType::Npm) + .await? + } Some(package_manager) => Some( package_manager::resolve_current_or_fallback_for(&cwd, package_manager).await?, ), @@ -192,6 +196,13 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result Date: Sat, 19 Sep 2026 13:05:10 +0800 Subject: [PATCH 8/8] test(cli): normalize bundled npm paths on Windows --- .../vp_cli_snapshots/tests/cli_snapshots/redact.rs | 12 ++++++++++-- crates/vp_cli_snapshots/tests/redact_unit.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index fcce014b60..5cc9576271 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -62,7 +62,14 @@ static BUN_BUILD_HASH_RE: LazyLock = LazyLock::new(|| { // spelling used by the shared snapshots. static WINDOWS_MANAGED_NODE_BIN_RE: LazyLock = LazyLock::new(|| { regex::Regex::new( - r"(/.vite-plus/js_runtime/node/(?:|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?))/node\.exe\b", + r"(/.vite-plus/js_runtime/node/(?:|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?))/(node|npm|npx)\.(?:exe|cmd)\b", + ) + .unwrap() +}); +// Bash PATH output uses the runtime root on Windows and its bin directory on Unix. +static WINDOWS_MANAGED_NODE_PATH_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r#"(export PATH="/.vite-plus/js_runtime/node/(?:|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?))(:\$PATH")"#, ) .unwrap() }); @@ -447,7 +454,8 @@ pub fn redact_output( // Normalize platform-specific managed executable paths and missing-command // diagnostics before applying the general version redactions below. - output = WINDOWS_MANAGED_NODE_BIN_RE.replace_all(&output, "${1}/bin/node").into_owned(); + output = WINDOWS_MANAGED_NODE_BIN_RE.replace_all(&output, "${1}/bin/${2}").into_owned(); + output = WINDOWS_MANAGED_NODE_PATH_RE.replace_all(&output, "${1}/bin${2}").into_owned(); output = WINDOWS_MANAGED_PM_BIN_RE.replace_all(&output, "${1}").into_owned(); output = COMMAND_NOT_FOUND_RE.replace_all(&output, "${1}program not found").into_owned(); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index 7a20411af9..71a80ff3ad 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -94,6 +94,12 @@ fn normalizes_managed_executable_paths_and_missing_commands() { "\n", r#""pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm.cmd""#, "\n", + r#""npm": "/.vite-plus/js_runtime/node/22.18.0/npm.cmd""#, + "\n", + r#""npx": "/.vite-plus/js_runtime/node/22.18.0/npx.cmd""#, + "\n", + r#"export PATH="/.vite-plus/js_runtime/node/22.18.0:$PATH""#, + "\n", "error: Command execution failed: No such file or directory (os error 2)\n", ) .to_owned(); @@ -104,6 +110,12 @@ fn normalizes_managed_executable_paths_and_missing_commands() { "\n", r#""pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm""#, "\n", + r#""npm": "/.vite-plus/js_runtime/node//bin/npm""#, + "\n", + r#""npx": "/.vite-plus/js_runtime/node//bin/npx""#, + "\n", + r#"export PATH="/.vite-plus/js_runtime/node//bin:$PATH""#, + "\n", "error: Command execution failed: program not found\n", ) );