From ca3791ce08e4f38357279b617fd5e16bf3613126 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 29 Jun 2026 09:31:44 +0800 Subject: [PATCH 1/5] wip --- .../vite_global_cli/src/commands/env/exec.rs | 9 ++- .../vite_global_cli/src/commands/env/mod.rs | 4 +- .../src/commands/global/install.rs | 12 ++-- .../src/commands/global/mod.rs | 12 ++-- crates/vite_global_cli/src/commands/mod.rs | 4 +- crates/vite_global_cli/src/commands/vpx.rs | 12 +++- crates/vite_global_cli/src/js_executor.rs | 8 +-- crates/vite_global_cli/src/shim/corepack.rs | 11 ++- crates/vite_global_cli/src/shim/dispatch.rs | 45 +++++++----- crates/vite_js_runtime/src/lib.rs | 4 +- crates/vite_js_runtime/src/runtime.rs | 69 +++++++++++++++++++ crates/vite_setup/src/install.rs | 4 +- .../assert-core-path.js | 24 +++++++ .../env-node-child-process-npm/snap.txt | 8 ++- .../env-node-child-process-npm/steps.json | 1 + packages/tools/src/snap-test.ts | 2 +- 16 files changed, 181 insertions(+), 48 deletions(-) create mode 100644 packages/cli/snap-tests-global/env-node-child-process-npm/assert-core-path.js diff --git a/crates/vite_global_cli/src/commands/env/exec.rs b/crates/vite_global_cli/src/commands/env/exec.rs index abe75fb2e3..3d8c5fc6c6 100644 --- a/crates/vite_global_cli/src/commands/env/exec.rs +++ b/crates/vite_global_cli/src/commands/env/exec.rs @@ -9,7 +9,7 @@ use std::process::ExitStatus; -use vite_js_runtime::NodeProvider; +use vite_js_runtime::{NodeProvider, ensure_node_core_bin_prefix}; use vite_shared::{env_vars, format_path_prepended}; use crate::{ @@ -137,10 +137,9 @@ async fn execute_with_version( std::env::remove_var(env_vars::VP_TOOL_RECURSION); } - // 4. Build PATH with node bin dir first (uses platform-specific separator) - // Always prepend to ensure the requested Node version is first in PATH - let node_bin_dir = runtime.get_bin_prefix(); - let new_path = format_path_prepended(node_bin_dir.as_path()); + // 4. Build PATH with the limited core bin dir first (uses platform-specific separator) + let core_bin_dir = ensure_node_core_bin_prefix(&runtime.get_binary_path())?; + let new_path = format_path_prepended(core_bin_dir.as_path()); // 5. Execute command let (cmd, args) = command.split_first().unwrap(); diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index c6a2872eec..f0d7ca0e4b 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -156,14 +156,14 @@ async fn print_env(cwd: AbsolutePathBuf) -> Result { // Resolve the Node.js version for the current directory let resolution = config::resolve_version(&cwd).await?; - // Get the node bin directory + // Get the limited core bin directory let runtime = vite_js_runtime::download_runtime( vite_js_runtime::JsRuntimeType::Node, &resolution.version, ) .await?; - let bin_dir = runtime.get_bin_prefix(); + let bin_dir = runtime.ensure_core_bin_prefix()?; let snippet = match detect_shell() { Shell::NuShell => { format!("$env.PATH = ($env.PATH | prepend \"{}\")", bin_dir.as_path().display()) diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index 0b09e52493..8afb277ff5 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -14,7 +14,7 @@ use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use owo_colors::OwoColorize; use tokio::process::Command; use uuid::Uuid; -use vite_js_runtime::NodeProvider; +use vite_js_runtime::{NodeProvider, ensure_node_core_bin_prefix}; use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{format_path_prepended, output}; @@ -154,6 +154,10 @@ pub async fn install( let node_bin_dir = runtime.get_bin_prefix(); let npm_path = if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; + let node_core_dir = match ensure_node_core_bin_prefix(&runtime.get_binary_path()) { + Ok(path) => path, + Err(error) => return Err((None, Error::RuntimeDownload(error))), + }; // 3. Install packages in parallel let mut packages = IndexMap::::new(); @@ -205,7 +209,7 @@ pub async fn install( installs.push(async { ( package_name.clone(), - install_one(package_name, package.spec, &npm_path, &node_bin_dir).await, + install_one(package_name, package.spec, &npm_path, &node_core_dir).await, ) }); } @@ -527,7 +531,7 @@ async fn install_one( package_name: &str, package_spec: &str, npm_path: &AbsolutePathBuf, - node_bin_dir: &AbsolutePathBuf, + node_core_dir: &AbsolutePathBuf, ) -> Result<(InstalledPackage, File), Error> { // 1. Create an immutable install directory. let install_id = new_install_id(); @@ -540,7 +544,7 @@ async fn install_one( let output = Command::new(npm_path.as_path()) .args(["install", "-g", "--no-fund", &package_spec]) .env("npm_config_prefix", install_dir.as_path()) - .env("PATH", format_path_prepended(node_bin_dir.as_path())) + .env("PATH", format_path_prepended(node_core_dir.as_path())) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) diff --git a/crates/vite_global_cli/src/commands/global/mod.rs b/crates/vite_global_cli/src/commands/global/mod.rs index 57530e8ae5..fefb5d04b3 100644 --- a/crates/vite_global_cli/src/commands/global/mod.rs +++ b/crates/vite_global_cli/src/commands/global/mod.rs @@ -13,6 +13,7 @@ use futures::{StreamExt, stream::FuturesUnordered}; use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use tar::Archive; use tokio::process::Command; +use vite_js_runtime::ensure_node_core_bin_prefix; use vite_path::{AbsolutePathBuf, current_dir}; use vite_shared::format_path_prepended; @@ -33,7 +34,7 @@ struct PackageVersion { struct NpmRegistry { npm_path: AbsolutePathBuf, - node_bin_dir: AbsolutePathBuf, + node_core_dir: AbsolutePathBuf, } impl NpmRegistry { @@ -49,12 +50,13 @@ impl NpmRegistry { let node_bin_dir = runtime.get_bin_prefix(); let npm_path = if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; + let node_core_dir = ensure_node_core_bin_prefix(&runtime.get_binary_path())?; - Ok(Self { npm_path, node_bin_dir }) + Ok(Self { npm_path, node_core_dir }) } async fn latest_package_version(&self, package_spec: &str) -> Result { - let output = npm_view(&self.npm_path, &self.node_bin_dir, package_spec, "version").await?; + let output = npm_view(&self.npm_path, &self.node_core_dir, package_spec, "version").await?; parse_npm_view_version(&output) } @@ -62,13 +64,13 @@ impl NpmRegistry { async fn npm_view( npm_path: &AbsolutePathBuf, - node_bin_dir: &AbsolutePathBuf, + node_core_dir: &AbsolutePathBuf, package_spec: &str, field: &str, ) -> Result, Error> { let output = Command::new(npm_path.as_path()) .args(["view", package_spec, field, "--json"]) - .env("PATH", format_path_prepended(node_bin_dir.as_path())) + .env("PATH", format_path_prepended(node_core_dir.as_path())) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 18679f0de6..57c8df764d 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -75,8 +75,8 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu executor.ensure_cli_runtime().await? }; - let node_bin_prefix = runtime.get_bin_prefix(); - // Use dedupe_anywhere=true to check if node bin already exists anywhere in PATH + let node_bin_prefix = runtime.ensure_core_bin_prefix()?; + // Use dedupe_anywhere=true to check if the core bin already exists anywhere in PATH let options = PrependOptions { dedupe_anywhere: true }; if prepend_to_path_env(&node_bin_prefix, options) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); diff --git a/crates/vite_global_cli/src/commands/vpx.rs b/crates/vite_global_cli/src/commands/vpx.rs index 53f840d0ea..78d1a58852 100644 --- a/crates/vite_global_cli/src/commands/vpx.rs +++ b/crates/vite_global_cli/src/commands/vpx.rs @@ -163,9 +163,15 @@ async fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &Absolut } }; - // Prepend Node.js bin dir to PATH - let node_bin_dir = node_path.parent().expect("Node has no parent directory"); - let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); + // Prepend Node.js core bin dir to PATH + let node_core_dir = match vite_js_runtime::ensure_node_core_bin_prefix(&node_path) { + Ok(path) => path, + Err(e) => { + output::error(&format!("vpx: Failed to prepare Node.js core PATH: {e}")); + return 1; + } + }; + let _ = prepend_to_path_env(&node_core_dir, PrependOptions::default()); // Prepend local node_modules/.bin dirs to PATH prepend_node_modules_bin_to_path(cwd); diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 585512d92e..648eae4b38 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -232,7 +232,7 @@ impl JsExecutor { // Use project's runtime based on its devEngines.runtime configuration let runtime = self.ensure_project_runtime(project_path).await?; let node_binary = runtime.get_binary_path(); - let bin_prefix = runtime.get_bin_prefix(); + let bin_prefix = runtime.ensure_core_bin_prefix()?; self.run_js_entry(project_path, &node_binary, &bin_prefix, args).await } @@ -243,7 +243,7 @@ impl JsExecutor { ) -> Result { let runtime = self.ensure_project_runtime(project_path).await?; let node_binary = runtime.get_binary_path(); - let bin_prefix = runtime.get_bin_prefix(); + let bin_prefix = runtime.ensure_core_bin_prefix()?; self.run_js_entry_output(project_path, &node_binary, &bin_prefix, args).await } @@ -258,7 +258,7 @@ impl JsExecutor { ) -> Result { let runtime = self.ensure_cli_runtime().await?; let node_binary = runtime.get_binary_path(); - let bin_prefix = runtime.get_bin_prefix(); + let bin_prefix = runtime.ensure_core_bin_prefix()?; let scripts_dir = self.get_scripts_dir()?; let entry_point = scripts_dir.join("bin.js"); @@ -285,7 +285,7 @@ impl JsExecutor { ) -> Result { let runtime = self.ensure_cli_runtime().await?; let node_binary = runtime.get_binary_path(); - let bin_prefix = runtime.get_bin_prefix(); + let bin_prefix = runtime.ensure_core_bin_prefix()?; self.run_js_entry(project_path, &node_binary, &bin_prefix, args).await } diff --git a/crates/vite_global_cli/src/shim/corepack.rs b/crates/vite_global_cli/src/shim/corepack.rs index f3b5cd6512..050386998b 100644 --- a/crates/vite_global_cli/src/shim/corepack.rs +++ b/crates/vite_global_cli/src/shim/corepack.rs @@ -134,9 +134,16 @@ async fn resolve_corepack_invocation() -> Result { }; if let Some(corepack_path) = corepack_path { // The bundled corepack sits in the same bin directory as node; - // prepend it so corepack's child processes see the same runtime. + // prepend the limited core bin so child processes see the same runtime. if let Some(node_bin_dir) = corepack_path.parent() { - let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); + let node_path = if cfg!(windows) { + node_bin_dir.join("node.exe") + } else { + node_bin_dir.join("node") + }; + if let Ok(node_core_dir) = vite_js_runtime::ensure_node_core_bin_prefix(&node_path) { + let _ = prepend_to_path_env(&node_core_dir, PrependOptions::default()); + } } // Match the core-tool dispatch: nested core-tool shims pass through. // SAFETY: Setting env vars at this point before exec/spawn is safe diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 47776f7a31..4a51f9116a 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -9,6 +9,7 @@ use vite_install::package_manager::{ PackageManagerType, download_package_manager, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; +use vite_js_runtime::ensure_node_core_bin_prefix; use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; @@ -716,15 +717,15 @@ async fn resolve_matching_package_manager_tool( async fn prepend_js_child_process_path_env( cwd: &AbsolutePath, - node_bin_dir: &AbsolutePath, + node_core_dir: &AbsolutePath, ) -> Result<(), Error> { - let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); + let _ = prepend_to_path_env(node_core_dir, PrependOptions::default()); let Some(npm_path) = resolve_matching_package_manager_tool(cwd, "npm").await? else { return Ok(()); }; if let Some(pm_bin_dir) = npm_path.parent() - && pm_bin_dir != node_bin_dir + && pm_bin_dir != node_core_dir { let _ = prepend_to_path_env(pm_bin_dir, PrependOptions::default()); } @@ -878,7 +879,14 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // version was selected from `packageManager`, put that PM bin dir first so // nested invocations see the same PM version while recursion prevention is set. let node_bin_dir = node_path.parent().expect("Node has no parent directory"); - if let Err(e) = prepend_js_child_process_path_env(&cwd, node_bin_dir).await { + let node_core_dir = match ensure_node_core_bin_prefix(&node_path) { + Ok(path) => path, + Err(e) => { + eprintln!("vp: Failed to prepare Node.js core PATH: {e}"); + return 1; + } + }; + if let Err(e) = prepend_js_child_process_path_env(&cwd, &node_core_dir).await { eprintln!("vp: Failed to resolve package manager for child process PATH: {e}"); return 1; } @@ -979,16 +987,21 @@ async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { if !node_version.is_empty() { match ensure_installed(&node_version).await { Ok(node_path) => { - if let Some(node_bin_dir) = node_path.parent() { - if let Err(e) = - prepend_js_child_process_path_env(&cwd, node_bin_dir).await - { - eprintln!( - "vp: Failed to resolve package manager for child \ - process PATH: {e}" - ); + let node_core_dir = match ensure_node_core_bin_prefix(&node_path) { + Ok(path) => path, + Err(e) => { + eprintln!("vp: Failed to prepare Node.js core PATH: {e}"); return 1; } + }; + if let Err(e) = + prepend_js_child_process_path_env(&cwd, &node_core_dir).await + { + eprintln!( + "vp: Failed to resolve package manager for child process \ + PATH: {e}" + ); + return 1; } } Err(e) => { @@ -1089,14 +1102,14 @@ pub(crate) async fn package_binary_invocation( .map_err(|e| format!("Binary '{tool}' not found: {e}"))?; // Prepare environment for recursive invocations - let node_bin_dir = - node_path.parent().ok_or_else(|| "Node has no parent directory".to_string())?; + let node_core_dir = ensure_node_core_bin_prefix(&node_path) + .map_err(|e| format!("Failed to prepare Node.js core PATH: {e}"))?; if let Ok(cwd) = current_dir() { - prepend_js_child_process_path_env(&cwd, node_bin_dir).await.map_err(|e| { + prepend_js_child_process_path_env(&cwd, &node_core_dir).await.map_err(|e| { format!("Failed to resolve package manager for child process PATH: {e}") })?; } else { - let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); + let _ = prepend_to_path_env(&node_core_dir, PrependOptions::default()); } // JS binaries (determined at install time and stored in metadata) run diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index 461f622430..88e6bac118 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -64,6 +64,6 @@ pub use provider::{ pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry}; pub use runtime::{ JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime, - download_runtime_for_project, download_runtime_with_provider, is_valid_version, - normalize_version, read_package_json, resolve_node_version, + download_runtime_for_project, download_runtime_with_provider, ensure_node_core_bin_prefix, + is_valid_version, normalize_version, read_package_json, resolve_node_version, }; diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 8f16ce9582..adb22fa857 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -58,6 +58,21 @@ impl JsRuntime { } } + /// Get the limited bin directory to prepend for child processes. + /// + /// Managed Node.js runtimes expose only node/npm/npx here so global bins + /// installed inside a runtime do not leak into child process PATH. + /// + /// # Errors + /// Returns an error when the core directory or tool links cannot be created. + pub fn ensure_core_bin_prefix(&self) -> Result { + if self.runtime_type == JsRuntimeType::Node && self.version != "system" { + ensure_node_core_bin_prefix(&self.get_binary_path()) + } else { + Ok(self.get_bin_prefix()) + } + } + /// Get the runtime type #[must_use] pub const fn runtime_type(&self) -> JsRuntimeType { @@ -92,6 +107,60 @@ impl JsRuntime { } } +/// Ensure the managed Node.js core bin directory exists and return it. +/// +/// The returned directory is a sibling of Node's real `bin` directory: +/// `.../node//core`. +/// +/// # Errors +/// Returns an error when the core directory or tool links cannot be created. +pub fn ensure_node_core_bin_prefix( + node_binary_path: &AbsolutePath, +) -> Result { + let bin_dir = node_binary_path + .parent() + .ok_or_else(|| std::io::Error::other("Node binary has no parent directory"))?; + let install_dir = bin_dir + .parent() + .ok_or_else(|| std::io::Error::other("Node bin directory has no parent directory"))?; + let core_dir = install_dir.join("core"); + std::fs::create_dir_all(core_dir.as_path())?; + + for (link_name, target_name) in node_core_tools() { + let target = bin_dir.join(target_name); + if !target.as_path().exists() { + continue; + } + let link = core_dir.join(link_name); + if std::fs::symlink_metadata(link.as_path()).is_ok() { + continue; + } + link_core_tool(target.as_path(), link.as_path())?; + } + + Ok(core_dir) +} + +#[cfg(unix)] +fn node_core_tools() -> &'static [(&'static str, &'static str)] { + &[("node", "node"), ("npm", "npm"), ("npx", "npx")] +} + +#[cfg(windows)] +fn node_core_tools() -> &'static [(&'static str, &'static str)] { + &[("node.exe", "node.exe"), ("npm.cmd", "npm.cmd"), ("npx.cmd", "npx.cmd")] +} + +#[cfg(unix)] +fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(windows)] +fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + std::fs::hard_link(target, link).or_else(|_| std::fs::copy(target, link).map(|_| ())) +} + /// Download and cache a JavaScript runtime /// /// # Arguments diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 879af4f4a5..65219316ed 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -342,7 +342,9 @@ async fn run_pnpm_install( args: &[&str], registry: Option<&str>, ) -> Result { - let node_bin = node_runtime.get_bin_prefix(); + let node_bin = node_runtime.ensure_core_bin_prefix().map_err(|error| { + Error::Setup(format!("Failed to prepare Node.js core PATH: {error}").into()) + })?; let pnpm_bin = pnpm_entry.parent().ok_or_else(|| { Error::Setup(format!("pnpm entry has no parent: {}", pnpm_entry.as_path().display()).into()) })?; diff --git a/packages/cli/snap-tests-global/env-node-child-process-npm/assert-core-path.js b/packages/cli/snap-tests-global/env-node-child-process-npm/assert-core-path.js new file mode 100644 index 0000000000..fdd8679817 --- /dev/null +++ b/packages/cli/snap-tests-global/env-node-child-process-npm/assert-core-path.js @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process'; +import { chmodSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const runtimeBin = dirname(process.execPath); +const leakedBin = join(runtimeBin, 'runtime-leak-check'); + +try { + writeFileSync(leakedBin, '#!/bin/sh\necho leaked\n'); + chmodSync(leakedBin, 0o755); + + try { + const output = execFileSync('runtime-leak-check', { encoding: 'utf8' }).trim(); + console.log(`runtime bin leaked: ${output}`); + } catch { + console.log('runtime bin not leaked'); + } + + for (const tool of ['node', 'npm', 'npx']) { + console.log(execFileSync('which', [tool], { encoding: 'utf8' }).trim()); + } +} finally { + rmSync(leakedBin, { force: true }); +} diff --git a/packages/cli/snap-tests-global/env-node-child-process-npm/snap.txt b/packages/cli/snap-tests-global/env-node-child-process-npm/snap.txt index fec9abd1ed..55215f2f49 100644 --- a/packages/cli/snap-tests-global/env-node-child-process-npm/snap.txt +++ b/packages/cli/snap-tests-global/env-node-child-process-npm/snap.txt @@ -1,5 +1,11 @@ > cd node && test "$(npm --version)" = "$(node ../print-version.js)" && node ../print-path.js -/js_runtime/node//bin/npm +/js_runtime/node//core/npm + +> cd node && node ../assert-core-path.js # child PATH exposes only node/npm/npx core links +runtime bin not leaked +/js_runtime/node//core/node +/js_runtime/node//core/npm +/js_runtime/node//core/npx > cd specific && test "$(npm --version)" = "$(node ../print-version.js)" && test "$(node ../print-version.js)" = "$(node ./print-package-json-pm-version.js)" && node ../print-path.js /package_manager/npm//npm/bin/npm diff --git a/packages/cli/snap-tests-global/env-node-child-process-npm/steps.json b/packages/cli/snap-tests-global/env-node-child-process-npm/steps.json index 25c1117544..8753b5c717 100644 --- a/packages/cli/snap-tests-global/env-node-child-process-npm/steps.json +++ b/packages/cli/snap-tests-global/env-node-child-process-npm/steps.json @@ -2,6 +2,7 @@ "ignoredPlatforms": ["win32"], "commands": [ "cd node && test \"$(npm --version)\" = \"$(node ../print-version.js)\" && node ../print-path.js", + "cd node && node ../assert-core-path.js # child PATH exposes only node/npm/npx core links", "cd specific && test \"$(npm --version)\" = \"$(node ../print-version.js)\" && test \"$(node ../print-version.js)\" = \"$(node ./print-package-json-pm-version.js)\" && node ../print-path.js" ] } diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index b8c0bdde1b..bc112be107 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -626,7 +626,7 @@ async function runTestCase( env['PATH'] = env['Path']; delete env['Path']; } - // The node shim prepends ~/.vite-plus/js_runtime/node/VERSION/bin/ to PATH, + // The node shim prepends ~/.vite-plus/js_runtime/node/VERSION/core/ to PATH, // which leaks into this process. Strip internal vite-plus paths so the test // environment simulates a clean user PATH (only the shim bin dir + system paths). const vitePlusJsRuntime = path.join(env['VP_HOME'], 'js_runtime'); From 72ac40e18271e68625260574b526c5933690c2bb Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 29 Jun 2026 10:14:19 +0800 Subject: [PATCH 2/5] windows --- crates/vite_js_runtime/src/runtime.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index adb22fa857..eb0c478999 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -109,7 +109,7 @@ impl JsRuntime { /// Ensure the managed Node.js core bin directory exists and return it. /// -/// The returned directory is a sibling of Node's real `bin` directory: +/// The returned directory is scoped to Node's version directory: /// `.../node//core`. /// /// # Errors @@ -120,10 +120,13 @@ pub fn ensure_node_core_bin_prefix( let bin_dir = node_binary_path .parent() .ok_or_else(|| std::io::Error::other("Node binary has no parent directory"))?; - let install_dir = bin_dir + #[cfg(unix)] + let core_dir = bin_dir .parent() - .ok_or_else(|| std::io::Error::other("Node bin directory has no parent directory"))?; - let core_dir = install_dir.join("core"); + .ok_or_else(|| std::io::Error::other("Node bin directory has no parent directory"))? + .join("core"); + #[cfg(windows)] + let core_dir = bin_dir.join("core"); std::fs::create_dir_all(core_dir.as_path())?; for (link_name, target_name) in node_core_tools() { @@ -158,7 +161,7 @@ fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io:: #[cfg(windows)] fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { - std::fs::hard_link(target, link).or_else(|_| std::fs::copy(target, link).map(|_| ())) + std::fs::hard_link(target, link) } /// Download and cache a JavaScript runtime From be82c48d410e658867977f5fb327c5561e710ef9 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 29 Jun 2026 11:09:42 +0800 Subject: [PATCH 3/5] fix --- crates/vite_js_runtime/src/runtime.rs | 34 ++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index eb0c478999..42b29c5a45 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -135,9 +135,6 @@ pub fn ensure_node_core_bin_prefix( continue; } let link = core_dir.join(link_name); - if std::fs::symlink_metadata(link.as_path()).is_ok() { - continue; - } link_core_tool(target.as_path(), link.as_path())?; } @@ -156,14 +153,45 @@ fn node_core_tools() -> &'static [(&'static str, &'static str)] { #[cfg(unix)] fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + if std::fs::symlink_metadata(link).is_ok() { + return Ok(()); + } std::os::unix::fs::symlink(target, link) } #[cfg(windows)] fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + if target + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd")) + { + return write_core_cmd_wrapper(target, link); + } + if std::fs::symlink_metadata(link).is_ok() { + return Ok(()); + } std::fs::hard_link(target, link) } +#[cfg(windows)] +fn write_core_cmd_wrapper(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + let target_name = target + .file_name() + .ok_or_else(|| std::io::Error::other("Node core tool has no filename"))? + .to_string_lossy(); + let wrapper = + format!("@ECHO OFF\r\nCALL \"%~dp0..\\{target_name}\" %*\r\nEXIT /B %ERRORLEVEL%\r\n"); + + if std::fs::read_to_string(link).is_ok_and(|content| content == wrapper) { + return Ok(()); + } + if std::fs::symlink_metadata(link).is_ok() { + std::fs::remove_file(link)?; + } + std::fs::write(link, wrapper) +} + /// Download and cache a JavaScript runtime /// /// # Arguments From 157a94fdc14756de3758fed8568886139ac2e34c Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 29 Jun 2026 11:12:07 +0800 Subject: [PATCH 4/5] clean up --- crates/vite_js_runtime/src/runtime.rs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 42b29c5a45..06bb0bde4b 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -135,6 +135,9 @@ pub fn ensure_node_core_bin_prefix( continue; } let link = core_dir.join(link_name); + if std::fs::symlink_metadata(link.as_path()).is_ok() { + continue; + } link_core_tool(target.as_path(), link.as_path())?; } @@ -153,9 +156,6 @@ fn node_core_tools() -> &'static [(&'static str, &'static str)] { #[cfg(unix)] fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { - if std::fs::symlink_metadata(link).is_ok() { - return Ok(()); - } std::os::unix::fs::symlink(target, link) } @@ -168,9 +168,6 @@ fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io:: { return write_core_cmd_wrapper(target, link); } - if std::fs::symlink_metadata(link).is_ok() { - return Ok(()); - } std::fs::hard_link(target, link) } @@ -183,12 +180,6 @@ fn write_core_cmd_wrapper(target: &std::path::Path, link: &std::path::Path) -> s let wrapper = format!("@ECHO OFF\r\nCALL \"%~dp0..\\{target_name}\" %*\r\nEXIT /B %ERRORLEVEL%\r\n"); - if std::fs::read_to_string(link).is_ok_and(|content| content == wrapper) { - return Ok(()); - } - if std::fs::symlink_metadata(link).is_ok() { - std::fs::remove_file(link)?; - } std::fs::write(link, wrapper) } From e7c2d33dee102ed5fb1e1a4bc05f26633e11f415 Mon Sep 17 00:00:00 2001 From: Liang Mi Date: Mon, 29 Jun 2026 11:57:30 +0800 Subject: [PATCH 5/5] clean up --- crates/vite_js_runtime/src/runtime.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 06bb0bde4b..5383c6c12d 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -135,10 +135,7 @@ pub fn ensure_node_core_bin_prefix( continue; } let link = core_dir.join(link_name); - if std::fs::symlink_metadata(link.as_path()).is_ok() { - continue; - } - link_core_tool(target.as_path(), link.as_path())?; + create_core_tool_link(target.as_path(), link.as_path())?; } Ok(core_dir) @@ -154,6 +151,23 @@ fn node_core_tools() -> &'static [(&'static str, &'static str)] { &[("node.exe", "node.exe"), ("npm.cmd", "npm.cmd"), ("npx.cmd", "npx.cmd")] } +fn create_core_tool_link(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { + if std::fs::symlink_metadata(link).is_ok() { + return Ok(()); + } + + match link_core_tool(target, link) { + Ok(()) => Ok(()), + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists + && std::fs::symlink_metadata(link).is_ok() => + { + Ok(()) + } + Err(error) => Err(error), + } +} + #[cfg(unix)] fn link_core_tool(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> { std::os::unix::fs::symlink(target, link)