From 5b3adce4817c52aca39618b01550d2a972ea6428 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 2 Mar 2026 14:21:33 +0800 Subject: [PATCH 1/6] fix(env): stop auto-writing .node-version file (#666) Remove the auto-write behavior where the shim would create a .node-version file when no version source existed. The file should only be written explicitly via `vp env pin`. --- crates/vite_js_runtime/src/runtime.rs | 64 +++++++------------ .../snap.txt | 14 +--- .../command-pack-npm10/snap.txt | 21 +----- .../snap.txt | 10 --- .../command-pack-pnpm10/snap.txt | 10 --- .../snap.txt | 4 -- .../command-pack-yarn4/snap.txt | 4 -- rfcs/js-runtime.md | 45 +------------ 8 files changed, 30 insertions(+), 142 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index b6853693c6..15332b1036 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -5,7 +5,7 @@ use vite_str::Str; use crate::{ Error, Platform, - dev_engines::{PackageJson, read_node_version_file, write_node_version_file}, + dev_engines::{PackageJson, read_node_version_file}, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider}, providers::NodeProvider, @@ -384,8 +384,7 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result tracing::debug!("Selected version source: {source:?}, version_req: {version_req:?}"); // Resolve version (if range/partial โ†’ exact) - let (version, should_write_back) = - resolve_version_for_project(&version_req, source, &provider, &cache_dir).await?; + let version = resolve_version_for_project(&version_req, &provider, &cache_dir).await?; // Check compatibility with lower priority sources check_version_compatibility(&version, source, &engines_node, &dev_engines_runtime); @@ -393,71 +392,51 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result tracing::info!("Resolved Node.js version: {version}"); let runtime = download_runtime(JsRuntimeType::Node, &version).await?; - // Write resolved version to .node-version (if resolution occurred) - if should_write_back { - if let Err(e) = write_node_version_file(project_path, &version).await { - tracing::warn!("Failed to write .node-version: {e}"); - } else { - tracing::info!("Using Node {version} - saved version to .node-version"); - } - } - Ok(runtime) } /// Resolve version requirement to an exact version. /// -/// Returns (resolved_version, should_write_back). +/// Returns the resolved exact version string. async fn resolve_version_for_project( version_req: &str, - _source: Option, provider: &NodeProvider, cache_dir: &AbsolutePath, -) -> Result<(Str, bool), Error> { +) -> Result { if version_req.is_empty() { // No source specified - fetch latest LTS from network tracing::debug!("No version source specified, fetching latest LTS from network"); - let version = provider.resolve_latest_version().await?; - return Ok((version, true)); + return provider.resolve_latest_version().await; } // Handle LTS aliases (lts/*, lts/iron, lts/-1) if NodeProvider::is_lts_alias(version_req) { tracing::debug!("Resolving LTS alias: {version_req}"); - let version = provider.resolve_lts_alias(version_req).await?; - // Don't write back - user explicitly specified an LTS alias - return Ok((version, false)); + return provider.resolve_lts_alias(version_req).await; } // Handle "latest" alias - resolves to absolute latest version (including non-LTS) if NodeProvider::is_latest_alias(version_req) { tracing::debug!("Resolving 'latest' alias"); - let version = provider.resolve_version("*").await?; - // Don't write back - user explicitly specified "latest" - return Ok((version, false)); + return provider.resolve_version("*").await; } // Check if it's an exact version if NodeProvider::is_exact_version(version_req) { let normalized = version_req.strip_prefix('v').unwrap_or(version_req); tracing::debug!("Using exact version: {normalized}"); - // Never write back exact versions - user explicitly specified the version - return Ok((normalized.into(), false)); + return Ok(normalized.into()); } // Check local cache first if let Some(cached) = provider.find_cached_version(version_req, cache_dir).await? { tracing::debug!("Found cached version {cached} satisfying {version_req}"); - // Don't write back - user specified a version requirement - return Ok((cached, false)); + return Ok(cached); } // Resolve from network tracing::debug!("Resolving version requirement from network: {version_req}"); - let version = provider.resolve_version(version_req).await?; - - // Don't write back - user specified a version requirement - Ok((version, false)) + provider.resolve_version(version_req).await } /// Check if the resolved version is compatible with lower priority sources. @@ -684,7 +663,7 @@ mod tests { } #[tokio::test] - async fn test_download_runtime_for_project_writes_back_when_no_version() { + async fn test_download_runtime_for_project_does_not_write_back_when_no_version() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -700,16 +679,13 @@ mod tests { "#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); + let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // .node-version is written only if no ancestor has one (write-back is - // suppressed when an ancestor .node-version exists, e.g. in a monorepo) - if tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap() { - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, format!("{version}\n")); - } + // .node-version should NOT be written (auto-write was removed) + assert!( + !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), + ".node-version should not be auto-created" + ); // package.json should remain unchanged let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); @@ -777,6 +753,12 @@ mod tests { // Should download latest Node.js assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should NOT write .node-version + assert!( + !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), + ".node-version should not be auto-created" + ); } #[tokio::test] diff --git a/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt index dcd99a5eff..461f800722 100644 --- a/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-npm10-with-workspace/snap.txt @@ -10,11 +10,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "output.log", "size": , @@ -46,7 +41,7 @@ "mode": 420 } ], - "entryCount": 7, + "entryCount": 6, "bundled": [] } ] @@ -170,11 +165,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "app-1.0.0.tgz", "size": , @@ -221,7 +211,7 @@ "mode": 420 } ], - "entryCount": 10, + "entryCount": 9, "bundled": [] } ] diff --git a/packages/cli/snap-tests-global/command-pack-npm10/snap.txt b/packages/cli/snap-tests-global/command-pack-npm10/snap.txt index d4efce7401..1835d19aae 100644 --- a/packages/cli/snap-tests-global/command-pack-npm10/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-npm10/snap.txt @@ -10,11 +10,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "output.log", "size": , @@ -36,7 +31,7 @@ "mode": 420 } ], - "entryCount": 5, + "entryCount": 4, "bundled": [] } ] @@ -53,11 +48,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "command-pack-npm10-1.0.0.tgz", "size": , @@ -84,7 +74,7 @@ "mode": 420 } ], - "entryCount": 6, + "entryCount": 5, "bundled": [] } ] @@ -101,11 +91,6 @@ "integrity": "sha512-", "filename": "command-pack-npm10-1.0.0.tgz", "files": [ - { - "path": ".node-version", - "size": , - "mode": 420 - }, { "path": "command-pack-npm10-1.0.0.tgz", "size": , @@ -132,7 +117,7 @@ "mode": 420 } ], - "entryCount": 6, + "entryCount": 5, "bundled": [] } ] diff --git a/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt index cb858c8d43..c7817703ac 100644 --- a/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-pnpm10-with-workspace/snap.txt @@ -1,7 +1,6 @@ > vp pm pack && rm -rf *.tgz # should pack current workspace root ๐Ÿ“ฆ command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version output.log package.json packages/app/package.json @@ -39,9 +38,6 @@ command-pack-pnpm10-with-workspace-1.0.0.tgz "version": "1.0.0", "filename": "command-pack-pnpm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "command-pack-pnpm10-with-workspace-1.0.0.tgz" }, @@ -107,7 +103,6 @@ Tarball Details > vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file ๐Ÿ“ฆ command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version app-1.0.0.tgz command-pack-pnpm10-with-workspace-1.0.0.tgz out.json @@ -125,7 +120,6 @@ Tarball Details > vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination ๐Ÿ“ฆ command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version app-1.0.0.tgz command-pack-pnpm10-with-workspace-1.0.0.tgz out.json @@ -143,7 +137,6 @@ Tarball Details > vp pm pack --pack-gzip-level 9 && rm -rf *.tgz # should pack with gzip compression level ๐Ÿ“ฆ command-pack-pnpm10-with-workspace@ Tarball Contents -.node-version app-1.0.0.tgz command-pack-pnpm10-with-workspace-1.0.0.tgz out.json @@ -164,9 +157,6 @@ command-pack-pnpm10-with-workspace-1.0.0.tgz "version": "1.0.0", "filename": "foo-command-pack-pnpm10-with-workspace-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "app-1.0.0.tgz" }, diff --git a/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt index ccdfcc03d9..ecfb9b7e5f 100644 --- a/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-pnpm10/snap.txt @@ -18,7 +18,6 @@ Options: > vp pm pack && rm -rf *.tgz # should pack current package ๐Ÿ“ฆ command-pack-pnpm10@ Tarball Contents -.node-version output.log package.json snap.txt @@ -29,7 +28,6 @@ command-pack-pnpm10-1.0.0.tgz > vp pm pack --out ./dist/package.tgz && rm -rf ./dist # should pack with output file ๐Ÿ“ฆ command-pack-pnpm10@ Tarball Contents -.node-version command-pack-pnpm10-1.0.0.tgz output.log package.json @@ -41,7 +39,6 @@ Tarball Details > vp pm pack --pack-destination ./dist && rm -rf ./dist # should pack with destination ๐Ÿ“ฆ command-pack-pnpm10@ Tarball Contents -.node-version command-pack-pnpm10-1.0.0.tgz output.log package.json @@ -56,9 +53,6 @@ Tarball Details "version": "1.0.0", "filename": "command-pack-pnpm10-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "command-pack-pnpm10-1.0.0.tgz" }, @@ -83,9 +77,6 @@ Tarball Details "version": "1.0.0", "filename": "command-pack-pnpm10-1.0.0.tgz", "files": [ - { - "path": ".node-version" - }, { "path": "command-pack-pnpm10-1.0.0.tgz" }, @@ -107,7 +98,6 @@ Tarball Details > vp pm pack -- --loglevel=warn && rm -rf *.tgz # should support pass through arguments ๐Ÿ“ฆ command-pack-pnpm10@ Tarball Contents -.node-version command-pack-pnpm10-1.0.0.tgz output.log package.json diff --git a/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt b/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt index aa7e4e1ea2..49e144a7fd 100644 --- a/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-yarn4-with-workspace/snap.txt @@ -10,7 +10,6 @@ โžค YN0000: ยท Done with warnings in ms ms > vp pm pack # should pack current workspace root -โžค YN0000: .node-version โžค YN0000: output.log โžค YN0000: package.json โžค YN0000: snap.txt @@ -19,7 +18,6 @@ โžค YN0000: Done in ms ms > vp pm pack --recursive # should pack all packages in workspace (uses workspaces foreach --all pack) -โžค YN0000: .node-version โžค YN0000: output.log โžค YN0000: package.json โžค YN0000: snap.txt @@ -50,7 +48,6 @@ Done in ms ms Done in ms ms > vp pm pack --out ./dist/package.tgz # should pack with output file -โžค YN0000: .node-version โžค YN0000: output.log โžค YN0000: package.json โžค YN0000: snap.txt @@ -60,7 +57,6 @@ Done in ms ms > vp pm pack --json # should pack with json output {"base":""} -{"location":".node-version"} {"location":"dist/package.tgz"} {"location":"output.log"} {"location":"package.json"} diff --git a/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt b/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt index 4c022f9291..ba931513ab 100644 --- a/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt +++ b/packages/cli/snap-tests-global/command-pack-yarn4/snap.txt @@ -1,5 +1,4 @@ > vp pm pack # should pack current package -โžค YN0000: .node-version โžค YN0000: output.log โžค YN0000: package.json โžค YN0000: snap.txt @@ -8,7 +7,6 @@ โžค YN0000: Done in ms ms > vp pm pack --out ./dist/package.tgz # should pack with output file -โžค YN0000: .node-version โžค YN0000: output.log โžค YN0000: package.json โžค YN0000: snap.txt @@ -18,7 +16,6 @@ > vp pm pack --json # should pack with json output {"base":""} -{"location":".node-version"} {"location":"dist/package.tgz"} {"location":"output.log"} {"location":"package.json"} @@ -27,7 +24,6 @@ {"output":"/package.tgz"} > vp pm pack -- --dry-run # should support pass through arguments -โžค YN0000: .node-version โžค YN0000: dist/package.tgz โžค YN0000: output.log โžค YN0000: package.json diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index e30ec95921..08629511a2 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -157,7 +157,6 @@ pub async fn download_runtime( /// Download runtime based on project's version configuration /// Reads from .node-version, engines.node, or devEngines.runtime (in priority order) /// Resolves semver ranges, downloads the matching version -/// Writes resolved version to .node-version for future use pub async fn download_runtime_for_project( project_path: &AbsolutePath, ) -> Result; @@ -209,7 +208,6 @@ use vite_path::AbsolutePathBuf; let project_path = AbsolutePathBuf::new("/path/to/project".into()).unwrap(); let runtime = download_runtime_for_project(&project_path).await?; // Version is resolved from .node-version > engines.node > devEngines.runtime -// Resolved version is saved to .node-version for future use ``` ## Cache Directory Structure @@ -407,52 +405,13 @@ When no version source exists: 1. Check local cache for installed Node.js versions 2. Use the **latest installed version** (if any exist) 3. If no cached versions exist, fetch and use latest LTS from network -4. Write the used version to `.node-version` -5. Print: `Using Node {version} - saved version to .node-version` This optimizes for: - Avoiding unnecessary network requests - Using what the user already has installed -- Establishing `.node-version` as the version source going forward -### Version Write-Back - -When `download_runtime_for_project` resolves a version and **no version source exists**, it writes the resolved version to `.node-version`. This establishes a version source for future use. - -**Write-back only occurs when no version source exists:** - -| Read From | Write To | Message | -| -------------------- | ---------------------- | ------------------------------------------------------- | -| `.node-version` | No write | - | -| `engines.node` | No write | - | -| `devEngines.runtime` | No write | - | -| No source | Create `.node-version` | "Using Node {version} - saved version to .node-version" | - -**Key behaviors:** - -1. Only write when no version source exists (respects user's explicit version requirements) -2. Use three-part version without `v` prefix with Unix line ending -3. Print informational message when saving version - -**Example: Before download (no version source)** - -Project structure: - -``` -my-project/ -โ””โ”€โ”€ package.json -``` - -**After download (.node-version created)** - -Project structure: - -``` -my-project/ -โ”œโ”€โ”€ .node-version # Contains: 24.5.0 -โ””โ”€โ”€ package.json -``` +**Note:** `.node-version` is only written explicitly via `vp env pin`. ## Download Sources @@ -723,7 +682,7 @@ pub enum Error { 9. โœ… Support semver ranges (^, ~, etc.) with version resolution 10. โœ… Version index caching with 1-hour TTL 11. โœ… Support both single runtime and array of runtimes in devEngines -12. โœ… Write resolved version to `.node-version` file +12. ~~Write resolved version to `.node-version` file~~ (removed โ€” `.node-version` is only written by `vp env pin`) 13. โœ… Optimized version resolution (skip network for exact versions, check local cache for ranges) 14. โœ… Multi-source version reading with priority: `.node-version` > `engines.node` > `devEngines.runtime` 15. โœ… Support `.node-version` file format (with/without v prefix, partial versions) From a245601d3e3ac87f94d96cdf46c1042a403ccfb9 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 2 Mar 2026 15:56:53 +0800 Subject: [PATCH 2/6] fix(env): use full version resolution chain in project runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After removing auto-write of .node-version (#666), `ensure_project_runtime()` still called `download_runtime_for_project()` which skips the user's `default_node_version` config and goes straight to LTS. This caused `vp run` and `vp exec` to use a different Node.js version than `vp env which` in unpinned projects. Fix by switching to `config::resolve_version()` which checks the full resolution chain (env var โ†’ .node-version โ†’ engines.node โ†’ devEngines.runtime โ†’ user default โ†’ LTS). --- crates/vite_global_cli/src/js_executor.rs | 13 +++++++------ .../package.json | 8 ++++++++ .../snap.txt | 17 +++++++++++++++++ .../steps.json | 10 ++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json create mode 100644 packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt create mode 100644 packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 871ba2002b..2dd5cebb41 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -10,7 +10,7 @@ use vite_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runti use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; -use crate::error::Error; +use crate::{commands::env::config, error::Error}; /// JavaScript executor using managed Node.js runtime. /// @@ -134,15 +134,17 @@ impl JsExecutor { /// Ensure the project runtime is downloaded and cached. /// - /// Uses the project's package.json `devEngines.runtime` configuration - /// to determine which Node.js version to use. + /// Uses `config::resolve_version()` which checks the full resolution chain + /// (env var, .node-version, engines.node, devEngines.runtime, user default, + /// then LTS fallback) to determine which Node.js version to use. pub async fn ensure_project_runtime( &mut self, project_path: &AbsolutePath, ) -> Result<&JsRuntime, Error> { if self.project_runtime.is_none() { tracing::debug!("Resolving project runtime from {:?}", project_path); - let runtime = download_runtime_for_project(project_path).await?; + let resolution = config::resolve_version(project_path).await?; + let runtime = download_runtime(JsRuntimeType::Node, &resolution.version).await?; self.project_runtime = Some(runtime); } Ok(self.project_runtime.as_ref().unwrap()) @@ -163,8 +165,7 @@ impl JsExecutor { /// If found, runs the local `dist/bin.js` directly. Otherwise, falls back /// to the global installation's `dist/bin.js`. /// - /// Uses the project's runtime (from its `devEngines.runtime` configuration). - /// This may write a `.node-version` file if the project has no version source. + /// Uses the project's runtime resolved via `config::resolve_version()`. /// For side-effect-free commands like `--version`, use [`delegate_with_cli_runtime`] instead. /// /// # Arguments diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json b/packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json new file mode 100644 index 0000000000..545d0359ca --- /dev/null +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/package.json @@ -0,0 +1,8 @@ +{ + "name": "delegate-respects-default-node-version", + "version": "1.0.0", + "private": true, + "scripts": { + "check-node": "node -e \"console.log(process.version)\"" + } +} diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt b/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt new file mode 100644 index 0000000000..3dc9fd7fef --- /dev/null +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt @@ -0,0 +1,17 @@ +> vp env default 22.12.0 # Set global default to 22.12.0 +โœ“ Default Node.js version set to + +> vp env which node # Should show 22.12.0 from 'default' source +/js_runtime/node//bin/node + Version:  22.12.0 + Source:  /config.json + +> vp run check-node # Should also use 22.12.0 + +> delegate-respects-default-node-version@ check-node +> node -e "console.log(process.version)" + +v + +> vp exec node -e "console.log(process.version)" # Should also use 22.12.0 +v22.12.0 diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json b/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json new file mode 100644 index 0000000000..2f55d415dd --- /dev/null +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json @@ -0,0 +1,10 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env default 22.12.0 # Set global default to 22.12.0", + "vp env which node # Should show 22.12.0 from 'default' source", + "vp run check-node # Should also use 22.12.0", + "vp exec node -e \"console.log(process.version)\" # Should also use 22.12.0" + ], + "after": ["vp env default lts # Restore default to LTS"] +} From f10b93cb28188e914cd0a49a509097648961ee31 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 2 Mar 2026 16:47:59 +0800 Subject: [PATCH 3/6] fix(env): preserve cache-aware resolution and fallback chain in project runtime Restructure ensure_project_runtime() to delegate to download_runtime_for_project() when project version sources exist, fixing two issues: - Range versions now check locally cached versions before network (P1) - Invalid engines.node correctly falls through to devEngines.runtime (P2) --- crates/vite_global_cli/src/js_executor.rs | 45 ++++++++++++++++--- .../package.json | 14 ++++++ .../snap.txt | 4 ++ .../steps.json | 6 +++ 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json create mode 100644 packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt create mode 100644 packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 2dd5cebb41..54811b6351 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -6,7 +6,9 @@ use std::process::ExitStatus; use tokio::process::Command; -use vite_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project}; +use vite_js_runtime::{ + JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, resolve_node_version, +}; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; @@ -134,17 +136,48 @@ impl JsExecutor { /// Ensure the project runtime is downloaded and cached. /// - /// Uses `config::resolve_version()` which checks the full resolution chain - /// (env var, .node-version, engines.node, devEngines.runtime, user default, - /// then LTS fallback) to determine which Node.js version to use. + /// Resolution order: + /// 1. Session override (env var from `vp env use`) + /// 2. Session override (file from `vp env use`) + /// 3. Project sources (.node-version, engines.node, devEngines.runtime) โ€” + /// delegates to `download_runtime_for_project()` for cache-aware resolution + /// 4. User default from config.json + /// 5. Latest LTS pub async fn ensure_project_runtime( &mut self, project_path: &AbsolutePath, ) -> Result<&JsRuntime, Error> { if self.project_runtime.is_none() { tracing::debug!("Resolving project runtime from {:?}", project_path); - let resolution = config::resolve_version(project_path).await?; - let runtime = download_runtime(JsRuntimeType::Node, &resolution.version).await?; + + // 1โ€“2. Session overrides: env var (from `vp env use`), then file + let session_version = vite_shared::EnvConfig::get() + .node_version + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + let session_version = match session_version { + Some(v) => Some(v), + None => config::read_session_version().await, + }; + if let Some(version) = session_version { + let runtime = download_runtime(JsRuntimeType::Node, &version).await?; + return Ok(self.project_runtime.insert(runtime)); + } + + // 3. Check if project has any version source + // (.node-version, engines.node, devEngines.runtime) + let has_project_source = + resolve_node_version(project_path, true).await.unwrap_or(None).is_some(); + + let runtime = if has_project_source { + // Project has version sources โ€” delegate to download_runtime_for_project + // which provides cache-aware range resolution and full fallback chain + download_runtime_for_project(project_path).await? + } else { + // No project source โ€” check user default from config, then LTS + let resolution = config::resolve_version(project_path).await?; + download_runtime(JsRuntimeType::Node, &resolution.version).await? + }; self.project_runtime = Some(runtime); } Ok(self.project_runtime.as_ref().unwrap()) diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json new file mode 100644 index 0000000000..dc1f13f7f9 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/package.json @@ -0,0 +1,14 @@ +{ + "name": "fallback-invalid-engines-to-dev-engines", + "version": "1.0.0", + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "22.12.0" + } + }, + "engines": { + "node": "invalid" + } +} diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt new file mode 100644 index 0000000000..7006c299fb --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt @@ -0,0 +1,4 @@ +> vp exec node -e "console.log(process.version)" # Should use devEngines.runtime 22.12.0, not LTS +warning: invalid version 'invalid' in engines.node, ignoring +warning: invalid version 'invalid' in engines.node, ignoring +v diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json new file mode 100644 index 0000000000..739d6d3e14 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json @@ -0,0 +1,6 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "vp exec node -e \"console.log(process.version)\" # Should use devEngines.runtime 22.12.0, not LTS" + ] +} From b2be85f68dfa72e7a5e6b20e13aff63fd582f2ec Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 2 Mar 2026 17:13:40 +0800 Subject: [PATCH 4/6] fix(env): fall back to user default when all project version sources are invalid When resolve_node_version() finds a version source with an invalid value (e.g., engines.node: "typo"), it returns Some(...) which previously routed to download_runtime_for_project(). That function falls to LTS when all sources are invalid, skipping the user's configured default. Add has_valid_version_source() to validate project sources before routing. When no valid source exists, fall through to config::resolve_version() which checks the user default from config.json before LTS. --- crates/vite_global_cli/src/js_executor.rs | 73 +++++++++++++++---- crates/vite_js_runtime/src/lib.rs | 4 +- crates/vite_js_runtime/src/runtime.rs | 36 +++++---- .../package.json | 8 ++ .../snap.txt | 6 ++ .../steps.json | 8 ++ 6 files changed, 107 insertions(+), 28 deletions(-) create mode 100644 packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json create mode 100644 packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt create mode 100644 packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 54811b6351..54693a75ef 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -7,7 +7,8 @@ use std::process::ExitStatus; use tokio::process::Command; use vite_js_runtime::{ - JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, resolve_node_version, + JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, is_valid_version, + read_package_json, resolve_node_version, }; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; @@ -155,26 +156,30 @@ impl JsExecutor { .node_version .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()); - let session_version = match session_version { - Some(v) => Some(v), - None => config::read_session_version().await, + let session_version = if session_version.is_some() { + session_version + } else { + config::read_session_version().await }; if let Some(version) = session_version { let runtime = download_runtime(JsRuntimeType::Node, &version).await?; return Ok(self.project_runtime.insert(runtime)); } - // 3. Check if project has any version source - // (.node-version, engines.node, devEngines.runtime) - let has_project_source = - resolve_node_version(project_path, true).await.unwrap_or(None).is_some(); - - let runtime = if has_project_source { - // Project has version sources โ€” delegate to download_runtime_for_project - // which provides cache-aware range resolution and full fallback chain + // 3. Check if project has any *valid* version source. + // resolve_node_version returns Some for any non-empty value, + // even invalid ones. We must validate before routing to + // download_runtime_for_project, which falls to LTS on all-invalid + // and would skip the user's configured default. + let has_valid_project_source = has_valid_version_source(project_path).await?; + + let runtime = if has_valid_project_source { + // At least one valid project source exists โ€” delegate to + // download_runtime_for_project for cache-aware range resolution + // and intra-project fallback chain download_runtime_for_project(project_path).await? } else { - // No project source โ€” check user default from config, then LTS + // No valid project source โ€” check user default from config, then LTS let resolution = config::resolve_version(project_path).await?; download_runtime(JsRuntimeType::Node, &resolution.version).await? }; @@ -286,6 +291,48 @@ impl JsExecutor { } } +/// Check whether a project directory has at least one valid version source. +/// +/// Uses `is_valid_version` (no warning side effects) to avoid duplicate +/// warnings when `download_runtime_for_project` or `config::resolve_version` +/// later call `normalize_version` on the same values. +/// +/// Returns `false` when all sources are missing or invalid, so the caller +/// can fall through to the user's configured default instead of LTS. +async fn has_valid_version_source( + project_path: &AbsolutePath, +) -> Result { + let resolution = resolve_node_version(project_path, true).await?; + let Some(ref r) = resolution else { + return Ok(false); + }; + + // Primary source is a valid version? + if is_valid_version(&r.version) { + return Ok(true); + } + + // Primary source invalid โ€” check package.json for valid fallbacks + let pkg_path = project_path.join("package.json"); + let Ok(Some(pkg)) = read_package_json(&pkg_path).await else { + return Ok(false); + }; + + let engines_valid = + pkg.engines.as_ref().and_then(|e| e.node.as_ref()).is_some_and(|v| is_valid_version(v)); + + let dev_engines_valid = !engines_valid + && pkg + .dev_engines + .as_ref() + .and_then(|de| de.runtime.as_ref()) + .and_then(|rt| rt.find_by_name("node")) + .filter(|r| !r.version.is_empty()) + .is_some_and(|r| is_valid_version(&r.version)); + + Ok(engines_valid || dev_engines_valid) +} + #[cfg(test)] mod tests { use serial_test::serial; diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index 4460080f49..71b7a488ec 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -51,6 +51,6 @@ pub use provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvi pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry}; pub use runtime::{ JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime, - download_runtime_for_project, download_runtime_with_provider, normalize_version, - read_package_json, resolve_node_version, + download_runtime_for_project, download_runtime_with_provider, 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 15332b1036..443d14bf89 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -491,34 +491,44 @@ fn check_constraint( } } -/// Normalize and validate a version string as semver (exact version or range) or LTS alias. -/// Trims whitespace and returns the normalized version, or None with a warning if invalid. -pub fn normalize_version(version: &Str, source: &str) -> Option { - // Trim leading/trailing whitespace - let trimmed: Str = version.trim().into(); +/// Check if a version string is valid (exact version, range, or LTS alias). +/// Trims whitespace before checking. Does not print warnings. +#[must_use] +pub fn is_valid_version(version: &str) -> bool { + let trimmed = version.trim(); if trimmed.is_empty() { - return None; + return false; } // Accept version aliases (lts/*, lts/iron, lts/-1, latest) - if NodeProvider::is_version_alias(&trimmed) { - return Some(trimmed); + if NodeProvider::is_version_alias(trimmed) { + return true; } // Try parsing as exact version (strip 'v' prefix for exact version check) - let without_v = trimmed.strip_prefix('v').unwrap_or(&trimmed); + let without_v = trimmed.strip_prefix('v').unwrap_or(trimmed); if Version::parse(without_v).is_ok() { - return Some(trimmed); + return true; } // Try parsing as range - if Range::parse(&trimmed).is_ok() { + Range::parse(trimmed).is_ok() +} + +/// Normalize and validate a version string as semver (exact version or range) or LTS alias. +/// Trims whitespace and returns the normalized version, or None with a warning if invalid. +pub fn normalize_version(version: &Str, source: &str) -> Option { + let trimmed: Str = version.trim().into(); + + if is_valid_version(&trimmed) { return Some(trimmed); } - // Invalid version - println!("warning: invalid version '{version}' in {source}, ignoring"); + // Invalid version โ€” print warning (only if non-empty, empty is just "not specified") + if !trimmed.is_empty() { + println!("warning: invalid version '{version}' in {source}, ignoring"); + } None } diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json new file mode 100644 index 0000000000..e77e0c4fe8 --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/package.json @@ -0,0 +1,8 @@ +{ + "name": "fallback-all-invalid-to-user-default", + "version": "1.0.0", + "private": true, + "engines": { + "node": "invalid" + } +} diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt new file mode 100644 index 0000000000..3b9822ebcd --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/snap.txt @@ -0,0 +1,6 @@ +> vp env default 22.12.0 # Set user default +โœ“ Default Node.js version set to + +> vp exec node -e "console.log(process.version)" # Should use default 22.12.0, not LTS +warning: invalid version 'invalid' in engines.node, ignoring +v diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json new file mode 100644 index 0000000000..eacbbc04eb --- /dev/null +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json @@ -0,0 +1,8 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env default 22.12.0 # Set user default", + "vp exec node -e \"console.log(process.version)\" # Should use default 22.12.0, not LTS" + ], + "after": ["vp env default lts # Restore default"] +} From 10844b928d7581ee623e4c8fe6c19aca0d2a0d6f Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 2 Mar 2026 20:59:13 +0800 Subject: [PATCH 5/6] feat(snap-test): add serial execution for tests that modify global state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that change the global default Node.js version (via `vp env default`) can interfere with each other when run concurrently. Add a `serial` field to steps.json that partitions tests into serial and parallel groups โ€” serial tests run first with concurrency 1, then parallel tests run with full CPU concurrency. --- .../snap.txt | 10 +++--- .../steps.json | 5 +-- .../steps.json | 1 + packages/tools/src/snap-test.ts | 31 ++++++++++++++----- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt b/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt index 3dc9fd7fef..5d3658f839 100644 --- a/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/snap.txt @@ -1,11 +1,6 @@ > vp env default 22.12.0 # Set global default to 22.12.0 โœ“ Default Node.js version set to -> vp env which node # Should show 22.12.0 from 'default' source -/js_runtime/node//bin/node - Version:  22.12.0 - Source:  /config.json - > vp run check-node # Should also use 22.12.0 > delegate-respects-default-node-version@ check-node @@ -15,3 +10,8 @@ v > vp exec node -e "console.log(process.version)" # Should also use 22.12.0 v22.12.0 + +> vp env which node # Should show 22.12.0 from 'default' source +/js_runtime/node//bin/node + Version:  22.12.0 + Source:  /config.json diff --git a/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json b/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json index 2f55d415dd..19db8e8903 100644 --- a/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json +++ b/packages/cli/snap-tests-global/delegate-respects-default-node-version/steps.json @@ -1,10 +1,11 @@ { + "serial": true, "ignoredPlatforms": ["win32"], "commands": [ "vp env default 22.12.0 # Set global default to 22.12.0", - "vp env which node # Should show 22.12.0 from 'default' source", "vp run check-node # Should also use 22.12.0", - "vp exec node -e \"console.log(process.version)\" # Should also use 22.12.0" + "vp exec node -e \"console.log(process.version)\" # Should also use 22.12.0", + "vp env which node # Should show 22.12.0 from 'default' source" ], "after": ["vp env default lts # Restore default to LTS"] } diff --git a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json index eacbbc04eb..5401c467a5 100644 --- a/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json +++ b/packages/cli/snap-tests-global/fallback-all-invalid-to-user-default/steps.json @@ -1,4 +1,5 @@ { + "serial": true, "ignoredPlatforms": ["win32"], "commands": [ "vp env default 22.12.0 # Set user default", diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index e581d0bbe2..2d02eecd75 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -125,7 +125,8 @@ export async function snapTest() { const casesDir = path.resolve(values.dir || 'snap-tests'); - const taskFunctions: (() => Promise)[] = []; + const serialTasks: (() => Promise)[] = []; + const parallelTasks: (() => Promise)[] = []; const missingStepsJson: string[] = []; for (const caseName of fs.readdirSync(casesDir)) { if (caseName.startsWith('.')) { @@ -135,12 +136,19 @@ export async function snapTest() { if (!fs.statSync(caseDir).isDirectory()) { continue; } - if (!fs.existsSync(path.join(caseDir, 'steps.json'))) { + const stepsPath = path.join(caseDir, 'steps.json'); + if (!fs.existsSync(stepsPath)) { missingStepsJson.push(caseName); continue; } if (caseName.includes(filter)) { - taskFunctions.push(() => runTestCase(caseName, tempTmpDir, casesDir, values['bin-dir'])); + const steps: Steps = JSON.parse(readFileSync(stepsPath, 'utf-8')); + const task = () => runTestCase(caseName, tempTmpDir, casesDir, values['bin-dir']); + if (steps.serial) { + serialTasks.push(task); + } else { + parallelTasks.push(task); + } } } @@ -150,14 +158,18 @@ export async function snapTest() { ); } - if (taskFunctions.length > 0) { + const totalCount = serialTasks.length + parallelTasks.length; + if (totalCount > 0) { const cpuCount = cpus().length; console.log( - 'Running %d test cases with concurrency limit of %d (CPU count)', - taskFunctions.length, + 'Running %d test cases (%d serial + %d parallel, concurrency limit %d)', + totalCount, + serialTasks.length, + parallelTasks.length, cpuCount, ); - await runWithConcurrencyLimit(taskFunctions, cpuCount); + await runWithConcurrencyLimit(serialTasks, 1); + await runWithConcurrencyLimit(parallelTasks, cpuCount); } process.exit(0); // Ensure exit even if there are pending timed-out steps } @@ -186,6 +198,11 @@ interface Steps { * These commands are not included in the snap output. */ after?: string[]; + /** + * If true, this test case will run serially before parallel tests. + * Use for tests that modify global shared state (e.g., `vp env default`). + */ + serial?: boolean; } async function runTestCase(name: string, tempTmpDir: string, casesDir: string, binDir?: string) { From 59dec20e425beec4dada421e1f89d2f2a1e3504d Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 2 Mar 2026 21:57:55 +0800 Subject: [PATCH 6/6] fix(env): fall back to devEngines.runtime when engines.node is invalid When engines.node contained an invalid version and devEngines.runtime was also present in the same package.json, `vp exec` skipped the fallback and went straight to user default/LTS. Extend the fallback match in resolve_version() to also handle VersionSource::EnginesNode, so invalid engines.node correctly falls through to devEngines.runtime. --- .../src/commands/env/config.rs | 69 +++++++++++++------ .../snap.txt | 6 ++ .../steps.json | 3 +- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index e9b9b4b685..eabf328c47 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -258,29 +258,34 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result + +> vp env which node # Should show devEngines.runtime source +warning: invalid version 'invalid' in engines.node, ignoring +/js_runtime/node//bin/node + Version:  22.12.0 + Source:  /package.json diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json index 739d6d3e14..e9d33e4e04 100644 --- a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/steps.json @@ -1,6 +1,7 @@ { "ignoredPlatforms": ["win32"], "commands": [ - "vp exec node -e \"console.log(process.version)\" # Should use devEngines.runtime 22.12.0, not LTS" + "vp exec node -e \"console.log(process.version)\" # Should use devEngines.runtime 22.12.0, not LTS", + "vp env which node # Should show devEngines.runtime source" ] }