From 2cd8aeaa910725ba6983718bf0ed6f797c847446 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Tue, 30 Jun 2026 19:54:16 +0800 Subject: [PATCH 1/4] fix(shim): block mismatched package manager shims --- crates/vite_global_cli/src/shim/dispatch.rs | 84 +++++++++++++++++-- .../npm-package-manager-mismatch/package.json | 5 ++ .../npm-package-manager-mismatch/snap.txt | 5 ++ .../npm-package-manager-mismatch/steps.json | 3 + 4 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json create mode 100644 packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt create mode 100644 packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 47776f7a31..15c80ff425 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -714,6 +714,33 @@ async fn resolve_matching_package_manager_tool( Ok(Some(package_manager_bin_path(&install_dir, bin_name))) } +fn package_manager_tool_mismatch_message( + cwd: &AbsolutePath, + tool: &str, +) -> Result, Error> { + let Some(invoked_type) = PackageManagerType::from_tool(tool) else { + return Ok(None); + }; + + let Some(resolution) = resolve_package_manager_from_package_json(cwd)? else { + return Ok(None); + }; + + if resolution.package_manager_type == invoked_type { + return Ok(None); + } + + let package_manager = resolution.package_manager_type; + let path = resolution.source_path.as_path().display(); + let source = resolution.source; + Ok(Some( + vite_str::format!( + "This project is configured to use {package_manager} because {path} has a \"{source}\" field\n\nUse `{package_manager}` instead of `{tool}`." + ) + .to_string(), + )) +} + async fn prepend_js_child_process_path_env( cwd: &AbsolutePath, node_bin_dir: &AbsolutePath, @@ -762,14 +789,6 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { return crate::commands::vpr::execute_vpr(args, &cwd).await; } - // Check recursion prevention - if already in a shim context, passthrough directly - // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. - // Package binaries are always resolved via metadata lookup, so they can't loop. - if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { - tracing::debug!("recursion prevention enabled for core tool"); - return passthrough_to_system(tool, args); - } - // Check bypass mode (explicit environment variable) if std::env::var(env_vars::VP_BYPASS).is_ok() { tracing::debug!("bypass mode enabled"); @@ -813,6 +832,37 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { return super::corepack::dispatch_corepack(args).await; } + // Package-manager shims should not run against a project that explicitly + // declares a different package manager. + if PackageManagerType::from_tool(tool).is_some() { + let cwd = match current_dir() { + Ok(path) => path, + Err(e) => { + eprintln!("vp: Failed to get current directory: {e}"); + return 1; + } + }; + match package_manager_tool_mismatch_message(&cwd, tool) { + Ok(Some(message)) => { + output::raw_stderr(&message); + return 1; + } + Ok(None) => {} + Err(e) => { + eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); + return 1; + } + } + } + + // Check recursion prevention - if already in a shim context, passthrough directly + // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. + // Package binaries are always resolved via metadata lookup, so they can't loop. + if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { + tracing::debug!("recursion prevention enabled for core tool"); + return passthrough_to_system(tool, args); + } + // Check if this is a package binary (not node/npm/npx) if !is_core_shim_tool(tool) { return dispatch_package_binary(tool, args).await; @@ -1503,6 +1553,24 @@ mod tests { } } + #[test] + fn test_package_manager_tool_mismatch_blocks_npm_for_pnpm_project() { + let temp = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + std::fs::write( + cwd.join("package.json"), + r#"{"name":"fixture","packageManager":"pnpm@10.33.2"}"#, + ) + .unwrap(); + + let message = package_manager_tool_mismatch_message(&cwd, "npm").unwrap().unwrap(); + + assert!(message.contains("This project is configured to use pnpm"), "got: {message}"); + assert!(message.contains("\"packageManager\" field"), "got: {message}"); + assert!(message.contains("Use `pnpm` instead of `npm`."), "got: {message}"); + assert!(package_manager_tool_mismatch_message(&cwd, "pnpm").unwrap().is_none()); + } + #[tokio::test] #[serial] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { diff --git a/packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json b/packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json new file mode 100644 index 0000000000..5ca451a1ba --- /dev/null +++ b/packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json @@ -0,0 +1,5 @@ +{ + "name": "npm-package-manager-mismatch", + "private": true, + "packageManager": "pnpm@10.33.2" +} diff --git a/packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt b/packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt new file mode 100644 index 0000000000..e4902e636d --- /dev/null +++ b/packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt @@ -0,0 +1,5 @@ +> npm --version || echo 'npm blocked' +This project is configured to use pnpm because /package.json has a "packageManager" field + +Use `pnpm` instead of `npm`. +npm blocked diff --git a/packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json b/packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json new file mode 100644 index 0000000000..22c2ab78da --- /dev/null +++ b/packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json @@ -0,0 +1,3 @@ +{ + "commands": ["npm --version || echo 'npm blocked'"] +} From 9d4ffb41051279aba06a626ff5b656228906e08d Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Tue, 30 Jun 2026 20:13:34 +0800 Subject: [PATCH 2/4] fix(shim): limit npm mismatch check to top-level calls --- crates/vite_global_cli/src/shim/dispatch.rs | 47 ++++++++++----------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 15c80ff425..988776478d 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -714,19 +714,12 @@ async fn resolve_matching_package_manager_tool( Ok(Some(package_manager_bin_path(&install_dir, bin_name))) } -fn package_manager_tool_mismatch_message( - cwd: &AbsolutePath, - tool: &str, -) -> Result, Error> { - let Some(invoked_type) = PackageManagerType::from_tool(tool) else { - return Ok(None); - }; - +fn npm_package_manager_mismatch_message(cwd: &AbsolutePath) -> Result, Error> { let Some(resolution) = resolve_package_manager_from_package_json(cwd)? else { return Ok(None); }; - if resolution.package_manager_type == invoked_type { + if resolution.package_manager_type == PackageManagerType::Npm { return Ok(None); } @@ -735,7 +728,7 @@ fn package_manager_tool_mismatch_message( let source = resolution.source; Ok(Some( vite_str::format!( - "This project is configured to use {package_manager} because {path} has a \"{source}\" field\n\nUse `{package_manager}` instead of `{tool}`." + "This project is configured to use {package_manager} because {path} has a \"{source}\" field\n\nUse `{package_manager}` instead of `npm`." ) .to_string(), )) @@ -832,9 +825,17 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { return super::corepack::dispatch_corepack(args).await; } - // Package-manager shims should not run against a project that explicitly - // declares a different package manager. - if PackageManagerType::from_tool(tool).is_some() { + // Check recursion prevention - if already in a shim context, passthrough directly + // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. + // Package binaries are always resolved via metadata lookup, so they can't loop. + if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { + tracing::debug!("recursion prevention enabled for core tool"); + return passthrough_to_system(tool, args); + } + + // Top-level npm should not run against a project that explicitly declares + // a different package manager. + if tool == "npm" { let cwd = match current_dir() { Ok(path) => path, Err(e) => { @@ -842,7 +843,7 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { return 1; } }; - match package_manager_tool_mismatch_message(&cwd, tool) { + match npm_package_manager_mismatch_message(&cwd) { Ok(Some(message)) => { output::raw_stderr(&message); return 1; @@ -855,14 +856,6 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } } - // Check recursion prevention - if already in a shim context, passthrough directly - // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. - // Package binaries are always resolved via metadata lookup, so they can't loop. - if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { - tracing::debug!("recursion prevention enabled for core tool"); - return passthrough_to_system(tool, args); - } - // Check if this is a package binary (not node/npm/npx) if !is_core_shim_tool(tool) { return dispatch_package_binary(tool, args).await; @@ -1563,12 +1556,18 @@ mod tests { ) .unwrap(); - let message = package_manager_tool_mismatch_message(&cwd, "npm").unwrap().unwrap(); + let message = npm_package_manager_mismatch_message(&cwd).unwrap().unwrap(); assert!(message.contains("This project is configured to use pnpm"), "got: {message}"); assert!(message.contains("\"packageManager\" field"), "got: {message}"); assert!(message.contains("Use `pnpm` instead of `npm`."), "got: {message}"); - assert!(package_manager_tool_mismatch_message(&cwd, "pnpm").unwrap().is_none()); + + std::fs::write( + cwd.join("package.json"), + r#"{"name":"fixture","packageManager":"npm@10.9.4"}"#, + ) + .unwrap(); + assert!(npm_package_manager_mismatch_message(&cwd).unwrap().is_none()); } #[tokio::test] From ed7d09f3be834e3521d0e19957dda5fbc75b8875 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Tue, 30 Jun 2026 20:31:58 +0800 Subject: [PATCH 3/4] fix(ci): avoid blocked npm shim calls --- .github/workflows/ci.yml | 4 ++-- .github/workflows/e2e-test.yml | 4 ++-- .github/workflows/test-vp-create.yml | 4 ++-- .../npm-package-manager-mismatch/package.json | 5 ----- .../npm-package-manager-mismatch/snap.txt | 5 ----- .../npm-package-manager-mismatch/steps.json | 3 --- .../.node-version | 0 .../package.json | 2 +- .../snap.txt | 11 +++++++++++ .../steps.json | 2 +- .../snap.txt | 8 -------- 11 files changed, 19 insertions(+), 29 deletions(-) delete mode 100644 packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json delete mode 100644 packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt delete mode 100644 packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json rename packages/cli/snap-tests-global/{shim-npm-ignores-mismatched-package-manager => shim-npm-blocks-mismatched-package-manager}/.node-version (100%) rename packages/cli/snap-tests-global/{shim-npm-ignores-mismatched-package-manager => shim-npm-blocks-mismatched-package-manager}/package.json (58%) create mode 100644 packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/snap.txt rename packages/cli/snap-tests-global/{shim-npm-ignores-mismatched-package-manager => shim-npm-blocks-mismatched-package-manager}/steps.json (70%) delete mode 100644 packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/snap.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0811fb336a..ae7ca51eb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -546,7 +546,7 @@ jobs: # Force-reinstall the published `latest` version rather than the local # build's version: mid-release-cycle the build can be ahead of the # registry, and `vp upgrade ` must download from npm. - $version = "$(npm view vite-plus version)".Trim() + $version = "$(pnpm view vite-plus version)".Trim() if (-not $version) { Write-Error "Failed to resolve latest published vite-plus version" exit 1 @@ -605,7 +605,7 @@ jobs: # Force-reinstall the published `latest` version rather than the local # build's version: mid-release-cycle the build can be ahead of the # registry, and `vp upgrade ` must download from npm. - version=$(npm view vite-plus version) + version=$(pnpm view vite-plus version) if [ -z "$version" ]; then echo "Failed to resolve latest published vite-plus version" exit 1 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e63f1a51f1..6afd471de6 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -113,8 +113,8 @@ jobs: # A release commit can leave `packages/{core,cli}` at a published # version (e.g. 0.1.22), which would make `pnpm pack` emit # `*-0.1.22.tgz` instead. Pin both to 0.0.0 so the names stay stable. - (cd packages/core && npm pkg set version=0.0.0) - (cd packages/cli && npm pkg set version=0.0.0) + (cd packages/core && pnpm pkg set version=0.0.0) + (cd packages/cli && pnpm pkg set version=0.0.0) cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. # Bun is uniquely strict about peer-dep resolution: diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 2a2973cc30..979ec5b210 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -99,8 +99,8 @@ jobs: # A release commit can leave `packages/{core,cli}` at a published # version (e.g. 0.1.22), which would make `pnpm pack` emit # `*-0.1.22.tgz` instead. Pin both to 0.0.0 so the names stay stable. - (cd packages/core && npm pkg set version=0.0.0) - (cd packages/cli && npm pkg set version=0.0.0) + (cd packages/core && pnpm pkg set version=0.0.0) + (cd packages/cli && pnpm pkg set version=0.0.0) cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. # Bun is uniquely strict about peer-dep resolution: diff --git a/packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json b/packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json deleted file mode 100644 index 5ca451a1ba..0000000000 --- a/packages/cli/snap-tests-global/npm-package-manager-mismatch/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "npm-package-manager-mismatch", - "private": true, - "packageManager": "pnpm@10.33.2" -} diff --git a/packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt b/packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt deleted file mode 100644 index e4902e636d..0000000000 --- a/packages/cli/snap-tests-global/npm-package-manager-mismatch/snap.txt +++ /dev/null @@ -1,5 +0,0 @@ -> npm --version || echo 'npm blocked' -This project is configured to use pnpm because /package.json has a "packageManager" field - -Use `pnpm` instead of `npm`. -npm blocked diff --git a/packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json b/packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json deleted file mode 100644 index 22c2ab78da..0000000000 --- a/packages/cli/snap-tests-global/npm-package-manager-mismatch/steps.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "commands": ["npm --version || echo 'npm blocked'"] -} diff --git a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/.node-version b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/.node-version similarity index 100% rename from packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/.node-version rename to packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/.node-version diff --git a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/package.json b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/package.json similarity index 58% rename from packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/package.json rename to packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/package.json index 904d8ea55a..49bcac5efc 100644 --- a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/package.json +++ b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/package.json @@ -1,5 +1,5 @@ { - "name": "shim-npm-ignores-mismatched-package-manager", + "name": "shim-npm-blocks-mismatched-package-manager", "version": "1.0.0", "private": true, "packageManager": "pnpm@10.19.0" diff --git a/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/snap.txt b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/snap.txt new file mode 100644 index 0000000000..c128d824e8 --- /dev/null +++ b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/snap.txt @@ -0,0 +1,11 @@ +> vp env exec node --version # Ensure Node.js is installed first +v20.18.0 + +> npm --version || echo "npm shim blocked mismatched packageManager" +This project is configured to use pnpm because /package.json has a "packageManager" field + +Use `pnpm` instead of `npm`. +npm shim blocked mismatched packageManager + +> test "$(npx --version)" != "10.19.0" && echo "npx shim ignored mismatched packageManager" +npx shim ignored mismatched packageManager diff --git a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/steps.json b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/steps.json similarity index 70% rename from packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/steps.json rename to packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/steps.json index 87795302a5..8974cd5a6a 100644 --- a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/steps.json +++ b/packages/cli/snap-tests-global/shim-npm-blocks-mismatched-package-manager/steps.json @@ -3,7 +3,7 @@ "env": {}, "commands": [ "vp env exec node --version # Ensure Node.js is installed first", - "test \"$(npm --version)\" != \"10.19.0\" && echo \"npm shim ignored mismatched packageManager\"", + "npm --version || echo \"npm shim blocked mismatched packageManager\"", "test \"$(npx --version)\" != \"10.19.0\" && echo \"npx shim ignored mismatched packageManager\"" ] } diff --git a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/snap.txt b/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/snap.txt deleted file mode 100644 index 72b32bee58..0000000000 --- a/packages/cli/snap-tests-global/shim-npm-ignores-mismatched-package-manager/snap.txt +++ /dev/null @@ -1,8 +0,0 @@ -> vp env exec node --version # Ensure Node.js is installed first -v20.18.0 - -> test "$(npm --version)" != "10.19.0" && echo "npm shim ignored mismatched packageManager" -npm shim ignored mismatched packageManager - -> test "$(npx --version)" != "10.19.0" && echo "npx shim ignored mismatched packageManager" -npx shim ignored mismatched packageManager From 0d1fef29d6896511a96fade3757f8c65dd635bb7 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Tue, 30 Jun 2026 20:37:58 +0800 Subject: [PATCH 4/4] fix(upgrade): mark internal pnpm install as recursive --- crates/vite_setup/src/install.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 879af4f4a5..e497f017b5 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -357,7 +357,8 @@ async fn run_pnpm_install( .args(args) .current_dir(version_dir) .env("CI", "true") - .env("PATH", path); + .env("PATH", path) + .env(vite_shared::env_vars::VP_TOOL_RECURSION, "1"); if let Some(registry_url) = registry { cmd.env(vite_shared::env_vars::NPM_CONFIG_REGISTRY, registry_url); @@ -873,7 +874,7 @@ mod tests { let node_binary = node_bin.join("node"); tokio::fs::write( &node_binary, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > invocation.txt\nprintf '%s' \"$PATH\" > path.txt\nprintf '%s' \"$npm_config_registry\" > registry.txt\n", + "#!/bin/sh\nprintf '%s\\n' \"$@\" > invocation.txt\nprintf '%s' \"$PATH\" > path.txt\nprintf '%s' \"$npm_config_registry\" > registry.txt\nprintf '%s' \"$VP_TOOL_RECURSION\" > recursion.txt\n", ) .await .unwrap(); @@ -913,6 +914,9 @@ mod tests { // falls back to its default registry. let registry = tokio::fs::read_to_string(version_dir.join("registry.txt")).await.unwrap(); assert_eq!(registry, ""); + + let recursion = tokio::fs::read_to_string(version_dir.join("recursion.txt")).await.unwrap(); + assert_eq!(recursion, "1"); } #[cfg(unix)]