From 0f68911edea23fa1b872e90709d01b83da4458ae Mon Sep 17 00:00:00 2001 From: BlankParticle Date: Sat, 25 Jul 2026 16:23:03 +0530 Subject: [PATCH 1/2] feat: add support for `.nvmrc` as node version source --- .../snapshots/cli_helper_message.md | 2 +- .../snapshots/command_env_use.md | 2 +- .../fixtures/command_env_which_nvmrc/.nvmrc | 1 + .../command_env_which_nvmrc/package.json | 1 + .../command_env_which_nvmrc/snapshots.toml | 9 ++ .../snapshots/command_env_which_nvmrc.md | 21 +++++ crates/vite_global_cli/src/cli.rs | 4 +- .../src/commands/env/config.rs | 93 ++++++++++++++++--- crates/vite_global_cli/src/help.rs | 2 +- crates/vite_global_cli/src/js_executor.rs | 38 +------- crates/vite_global_cli/src/shim/dispatch.rs | 2 +- crates/vite_js_runtime/src/dev_engines.rs | 59 +++++++++++- crates/vite_js_runtime/src/lib.rs | 2 +- crates/vite_js_runtime/src/runtime.rs | 61 +++++++++++- docs/guide/env.md | 5 +- 15 files changed, 239 insertions(+), 63 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/.nvmrc create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots/command_env_which_nvmrc.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 40f7a5d5fe..7cb3dfcbd0 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -467,7 +467,7 @@ Examples: Manage: vp env pin lts # Pin to latest LTS version - vp env install # Install version from .node-version / package.json + vp env install # Install version from .node-version / package.json / .nvmrc vp env use 20 # Use Node.js 20 for this shell session vp env use --unset # Remove session override vp env clean # Remove unused managed caches diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md index 336d8003bc..90d459cd38 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md @@ -12,7 +12,7 @@ Usage: vp env use [OPTIONS] [VERSION] Use a specific Node.js version for this shell session Arguments: - [VERSION] Version to use (e.g., "20", "20.18.0", "lts", "latest"). If omitted, reads from .node-version or package.json + [VERSION] Version to use (e.g., "20", "20.18.0", "lts", "latest"). If omitted, reads from .node-version, package.json, or .nvmrc Options: --unset Remove session override (revert to file-based resolution) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/.nvmrc b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/.nvmrc new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/.nvmrc @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/package.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/package.json @@ -0,0 +1 @@ +{} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots.toml new file mode 100644 index 0000000000..29ecad35ec --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "command_env_which_nvmrc" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", continue-on-failure = true }, + { argv = ["vp", "env", "which", "node"], comment = "Core tool - shows resolved Node.js binary path from .nvmrc", continue-on-failure = true }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots/command_env_which_nvmrc.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots/command_env_which_nvmrc.md new file mode 100644 index 0000000000..579d3a7f79 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which_nvmrc/snapshots/command_env_which_nvmrc.md @@ -0,0 +1,21 @@ +# command_env_which_nvmrc + +## `vp env exec node --version` + +Ensure Node.js is installed first + +``` + +``` + +## `vp env which node` + +Core tool - shows resolved Node.js binary path from .nvmrc + +``` +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/js_runtime/node//bin/node + Version: 20.18.0 + Source: /.nvmrc +``` diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 6ba7ebdc98..45efdab5e6 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -419,7 +419,7 @@ Examples: #[command(visible_alias = "i")] Install { /// Version to install (e.g., "20", "20.18.0", "lts", "latest") - /// If not provided, installs the version from .node-version or package.json + /// If not provided, installs the version from .node-version, package.json, or .nvmrc version: Option, }, @@ -430,7 +430,7 @@ Examples: vp env use --unset # Clear the session override")] Use { /// Version to use (e.g., "20", "20.18.0", "lts", "latest"). - /// If omitted, reads from .node-version or package.json. + /// If omitted, reads from .node-version, package.json, or .nvmrc. version: Option, /// Remove session override (revert to file-based resolution) diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index b686837cfe..6b632d3d28 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize}; use vite_js_runtime::{ - NodeProvider, VersionSource, is_valid_version, normalize_version, read_package_json, - resolve_node_version, + NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, + read_package_json, resolve_node_version, }; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -198,8 +198,9 @@ pub async fn delete_session_version() -> Result<(), Error> { /// 2. `.node-version` file in current or parent directories /// 3. `package.json#devEngines.runtime` in current or parent directories /// 4. `package.json#engines.node` in current or parent directories -/// 5. User default from config.json -/// 6. Latest LTS version +/// 5. `.nvmrc` file in current or parent directories +/// 6. User default from config.json +/// 7. Latest LTS version pub async fn resolve_version(cwd: &AbsolutePath) -> Result { // Session override via environment variable (set by `vp env use`) if let Some(env_version) = vite_shared::EnvConfig::get().node_version { @@ -270,10 +271,7 @@ pub(crate) async fn resolve_project_version_source( // Invalid version from a project source: try lower-priority sources in the same directory. // This mirrors the fallback logic in download_runtime_for_project(). - if !matches!( - resolution.source, - VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime - ) { + if matches!(resolution.source, VersionSource::NvmrcFile) { return Ok(None); } @@ -281,13 +279,12 @@ pub(crate) async fn resolve_project_version_source( return Ok(None); }; let package_json_path = project_root.join("package.json"); - let Ok(Some(pkg)) = read_package_json(&package_json_path).await else { - return Ok(None); - }; + let pkg = read_package_json(&package_json_path).await.ok().flatten(); if matches!(resolution.source, VersionSource::NodeVersionFile) && let Some(version) = pkg - .dev_engines_runtime("node") + .as_ref() + .and_then(|pkg| pkg.dev_engines_runtime("node")) .and_then(|r| r.version.clone()) .and_then(|v| validate_version_spec(&v, "devEngines.runtime", warn_invalid)) { @@ -299,9 +296,12 @@ pub(crate) async fn resolve_project_version_source( })); } - if let Some(version) = pkg - .engines + if matches!( + resolution.source, + VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime + ) && let Some(version) = pkg .as_ref() + .and_then(|pkg| pkg.engines.as_ref()) .and_then(|e| e.node.clone()) .and_then(|v| validate_version_spec(&v, "engines.node", warn_invalid)) { @@ -313,6 +313,18 @@ pub(crate) async fn resolve_project_version_source( })); } + if let Some(version) = read_nvmrc_file(&project_root) + .await + .and_then(|v| validate_version_spec(&v, ".nvmrc", warn_invalid)) + { + return Ok(Some(ProjectVersionSource { + version, + source: ".nvmrc".into(), + source_path: project_root.join(".nvmrc"), + project_root, + })); + } + Ok(None) } @@ -619,6 +631,59 @@ mod tests { assert_eq!(resolution.source, VersionSource::DevEnginesRuntime); } + #[tokio::test] + async fn test_resolve_version_from_nvmrc() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + tokio::fs::write(temp_path.join(".nvmrc"), "22.22.0\n").await.unwrap(); + + let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(source.version, "22.22.0"); + assert_eq!(source.source, ".nvmrc"); + assert_eq!(source.source_path, temp_path.join(".nvmrc")); + } + + #[tokio::test] + async fn test_node_version_takes_priority_over_nvmrc() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + tokio::fs::write(temp_path.join(".node-version"), "24.0.0\n").await.unwrap(); + tokio::fs::write(temp_path.join(".nvmrc"), "22.22.0\n").await.unwrap(); + + let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(source.version, "24.0.0"); + assert_eq!(source.source, ".node-version"); + assert_eq!(source.source_path, temp_path.join(".node-version")); + } + + #[tokio::test] + async fn test_project_source_inherits_parent_nvmrc() { + let temp_dir = TempDir::new().unwrap(); + let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let child = parent.join("child"); + tokio::fs::create_dir(&child).await.unwrap(); + tokio::fs::write(parent.join(".nvmrc"), "22.22.0\n").await.unwrap(); + + let source = resolve_project_version_source(&child, false).await.unwrap().unwrap(); + assert_eq!(source.version, "22.22.0"); + assert_eq!(source.source, ".nvmrc"); + assert_eq!(source.source_path, parent.join(".nvmrc")); + } + + #[tokio::test] + async fn test_project_source_falls_back_from_invalid_node_version_to_nvmrc() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write(temp_path.join(".node-version"), "not-a-version\n").await.unwrap(); + tokio::fs::write(temp_path.join(".nvmrc"), "22.22.0\n").await.unwrap(); + + let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(source.version, "22.22.0"); + assert_eq!(source.source, ".nvmrc"); + } + #[tokio::test] async fn test_resolve_version_node_version_takes_priority() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index e27f504de1..212c61ad1b 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -520,7 +520,7 @@ fn env_help_doc() -> HelpDoc { "", " Manage:", " vp env pin lts # Pin to latest LTS version", - " vp env install # Install version from .node-version / package.json", + " vp env install # Install version from .node-version / package.json / .nvmrc", " vp env use 20 # Use Node.js 20 for this shell session", " vp env use --unset # Remove session override", " vp env clean # Remove unused managed caches", diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 6e8842d278..9bfd48c1f6 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -6,10 +6,7 @@ use std::process::{ExitStatus, Output}; use tokio::process::Command; -use vite_js_runtime::{ - JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, is_valid_version, - read_package_json, resolve_node_version, -}; +use vite_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project}; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; @@ -162,7 +159,7 @@ impl JsExecutor { /// 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) — + /// 3. Project sources (.node-version, devEngines.runtime, engines.node, .nvmrc) — /// delegates to `download_runtime_for_project()` for cache-aware resolution /// 4. User default from config.json /// 5. Latest LTS @@ -466,35 +463,8 @@ fn local_vite_plus_is_older(local: &str, global: &str) -> bool { /// /// 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_runtime("node") - .and_then(|r| r.version.as_ref()) - .is_some_and(|v| is_valid_version(v)); - - Ok(engines_valid || dev_engines_valid) +async fn has_valid_version_source(project_path: &AbsolutePath) -> Result { + Ok(config::resolve_project_version_source(project_path, false).await?.is_some()) } /// Try to find system Node.js when in system-first mode (`vp env off`). diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 18d608aedd..d3d216db7b 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1293,7 +1293,7 @@ async fn cached_project_source_still_current( else { return Ok(!matches!( entry.source.as_str(), - ".node-version" | "devEngines.runtime" | "engines.node" + ".node-version" | "devEngines.runtime" | "engines.node" | ".nvmrc" )); }; diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs index 58b1e699de..303d49e5e2 100644 --- a/crates/vite_js_runtime/src/dev_engines.rs +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -1,7 +1,7 @@ -//! `.node-version` file reading and writing utilities. +//! Node.js version file reading and writing utilities. //! -//! This module provides utilities for working with `.node-version` files, -//! which are used to specify Node.js versions for projects. +//! This module provides utilities for working with `.node-version` and +//! `.nvmrc` files, which are used to specify Node.js versions for projects. //! //! For `PackageJson` types (devEngines, engines), see `vite_shared::package_json`. @@ -57,6 +57,31 @@ pub async fn read_node_version_file(project_path: &AbsolutePath) -> Option parse_node_version_content(&content) } +/// Read and parse a `.nvmrc` file from the project root. +/// +/// `.nvmrc` uses the same version syntax as `.node-version` for the values +/// Vite+ supports. Blank lines, comments, and reserved key/value pairs are +/// ignored. The nvm-specific `node` and `stable` aliases resolve to the latest +/// Node.js release. +pub async fn read_nvmrc_file(project_path: &AbsolutePath) -> Option { + let path = project_path.join(".nvmrc"); + let content = tokio::fs::read_to_string(&path).await.ok()?; + let mut versions = content.lines().filter_map(|line| { + let value = line.split_once('#').map_or(line, |(value, _)| value).trim(); + (!value.is_empty() && !value.contains('=')).then_some(value) + }); + let version = versions.next()?; + if versions.next().is_some() { + return None; + } + + match version { + "iojs" | "system" | "default" => None, + "node" | "stable" => Some("latest".into()), + _ => parse_node_version_content(version), + } +} + /// Write a version to the `.node-version` file. /// /// Creates the file if it doesn't exist, overwrites if it does. @@ -136,6 +161,34 @@ mod tests { assert_eq!(read_node_version_file(&temp_path).await, Some("22.13.1".into())); } + #[tokio::test] + async fn test_read_nvmrc_file() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + assert!(read_nvmrc_file(&temp_path).await.is_none()); + + tokio::fs::write(temp_path.join(".nvmrc"), "v22.13.1\n").await.unwrap(); + assert_eq!(read_nvmrc_file(&temp_path).await, Some("22.13.1".into())); + + tokio::fs::write(temp_path.join(".nvmrc"), "node\n").await.unwrap(); + assert_eq!(read_nvmrc_file(&temp_path).await, Some("latest".into())); + + tokio::fs::write( + temp_path.join(".nvmrc"), + "# Use the project version\n\nv22.13.1 # pinned\nNVM_SYMLINK_CURRENT=true\n", + ) + .await + .unwrap(); + assert_eq!(read_nvmrc_file(&temp_path).await, Some("22.13.1".into())); + + tokio::fs::write(temp_path.join(".nvmrc"), "20\n22\n").await.unwrap(); + assert!(read_nvmrc_file(&temp_path).await.is_none()); + + tokio::fs::write(temp_path.join(".nvmrc"), "system\n").await.unwrap(); + assert!(read_nvmrc_file(&temp_path).await.is_none()); + } + #[tokio::test] async fn test_write_node_version_file() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index c98b4d48fb..73c4a05211 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -54,7 +54,7 @@ mod providers; mod runtime; pub use dev_engines::{ - parse_node_version_content, read_node_version_file, write_node_version_file, + parse_node_version_content, read_node_version_file, read_nvmrc_file, write_node_version_file, }; pub use error::Error; pub use platform::{Arch, Os, Platform}; diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 8be4c61cb1..541c10f7bf 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -8,7 +8,7 @@ use vite_str::Str; use crate::{ Error, Platform, - dev_engines::{PackageJson, read_node_version_file}, + dev_engines::{PackageJson, read_node_version_file, read_nvmrc_file}, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider, ShasumsSignature}, providers::NodeProvider, @@ -304,8 +304,10 @@ pub enum VersionSource { NodeVersionFile, /// Version from `devEngines.runtime` in package.json DevEnginesRuntime, - /// Version from `engines.node` in package.json (lowest priority) + /// Version from `engines.node` in package.json EnginesNode, + /// Version from `.nvmrc` file + NvmrcFile, } impl std::fmt::Display for VersionSource { @@ -314,6 +316,7 @@ impl std::fmt::Display for VersionSource { Self::NodeVersionFile => write!(f, ".node-version"), Self::EnginesNode => write!(f, "engines.node"), Self::DevEnginesRuntime => write!(f, "devEngines.runtime"), + Self::NvmrcFile => write!(f, ".nvmrc"), } } } @@ -339,6 +342,7 @@ pub struct VersionResolution { /// 1. `.node-version` file /// 2. `package.json#devEngines.runtime[name="node"]` /// 3. `package.json#engines.node` +/// 4. `.nvmrc` file /// /// If `walk_up` is true, walks up the directory tree checking each level until /// a version is found or the root is reached. @@ -407,6 +411,17 @@ pub async fn resolve_node_version( } } + // 4. Check .nvmrc after the native Vite+ and package.json sources + if let Some(version) = read_nvmrc_file(current).await { + let nvmrc_path = current.join(".nvmrc"); + return Ok(Some(VersionResolution { + version, + source: VersionSource::NvmrcFile, + source_path: Some(nvmrc_path), + project_root: Some(current.to_absolute_path_buf()), + })); + } + // Move to parent directory if walk_up is enabled if !walk_up { break; @@ -427,7 +442,8 @@ pub async fn resolve_node_version( /// Reads Node.js version from multiple sources with the following priority: /// 1. `.node-version` file (highest) /// 2. `devEngines.runtime` in package.json -/// 3. `engines.node` in package.json (lowest) +/// 3. `engines.node` in package.json +/// 4. `.nvmrc` file /// /// If no version source is found, uses the latest installed version from cache, /// or falls back to the latest LTS version from the network. @@ -473,6 +489,14 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result .and_then(|r| r.version.clone()) .and_then(|v| normalize_version(&v, "devEngines.runtime")); + let nvmrc = if version_req.is_none() { + let root = + resolution.as_ref().and_then(|r| r.project_root.as_deref()).unwrap_or(project_path); + read_nvmrc_file(root).await.and_then(|v| normalize_version(&v, ".nvmrc")) + } else { + None + }; + // Determine the actual version requirement to use let (version_req, source) = if let Some(ref v) = version_req { (v.clone(), resolution.as_ref().map(|r| r.source)) @@ -481,6 +505,8 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result (v.clone(), Some(VersionSource::DevEnginesRuntime)) } else if let Some(ref v) = engines_node { (v.clone(), Some(VersionSource::EnginesNode)) + } else if let Some(ref v) = nvmrc { + (v.clone(), Some(VersionSource::NvmrcFile)) } else { (Str::default(), None) }; @@ -1184,6 +1210,7 @@ mod tests { assert_eq!(VersionSource::NodeVersionFile.to_string(), ".node-version"); assert_eq!(VersionSource::EnginesNode.to_string(), "engines.node"); assert_eq!(VersionSource::DevEnginesRuntime.to_string(), "devEngines.runtime"); + assert_eq!(VersionSource::NvmrcFile.to_string(), ".nvmrc"); } // ========================================== @@ -1553,6 +1580,34 @@ mod tests { assert_eq!(resolution.source, VersionSource::DevEnginesRuntime); } + #[tokio::test] + async fn test_resolve_node_version_from_nvmrc() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + tokio::fs::write(temp_path.join(".nvmrc"), "v20.18.0\n").await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::NvmrcFile); + assert_eq!(resolution.source_path, Some(temp_path.join(".nvmrc"))); + } + + #[tokio::test] + async fn test_resolve_node_version_nvmrc_has_lowest_priority() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + tokio::fs::write(temp_path.join(".nvmrc"), "20.18.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), r#"{"engines":{"node":"22.22.0"}}"#) + .await + .unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "22.22.0"); + assert_eq!(resolution.source, VersionSource::EnginesNode); + } + #[tokio::test] async fn test_resolve_node_version_priority() { let temp_dir = TempDir::new().unwrap(); diff --git a/docs/guide/env.md b/docs/guide/env.md index 8679c2eb34..ac2020c38c 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -11,7 +11,8 @@ The project Node.js version is resolved from these sources, in priority order: 1. `.node-version` file (current or parent directories) 2. `devEngines.runtime` in `package.json` (the [devEngines standard](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines)) 3. `engines.node` in `package.json` -4. The global default (`vp env default`), then the latest LTS +4. `.nvmrc` file (current or parent directories) +5. The global default (`vp env default`), then the latest LTS `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. @@ -112,7 +113,7 @@ vp env print # Print shell snippet for this session # Manage vp env pin lts # Pin the project to the latest LTS release -vp env install # Install the version from .node-version or package.json +vp env install # Install the version from .node-version, package.json, or .nvmrc vp env default lts # Set the global default version vp env use 20 # Use Node.js 20 for the current shell session vp env use --unset # Remove the session override From a942cc42f6e902b1fca5e7c14d774a16c06d57c5 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Thu, 30 Jul 2026 18:55:33 +0200 Subject: [PATCH 2/2] fix(env): complete nvmrc integration --- .../fixtures/command_env_install_nvmrc/.nvmrc | 1 + .../command_env_install_nvmrc/package.json | 3 + .../command_env_install_nvmrc/snapshots.toml | 8 ++ .../snapshots/command_env_install_nvmrc.md | 12 ++ .../src/commands/env/doctor.rs | 126 +++++++++++++++--- .../vite_global_cli/src/commands/env/mod.rs | 39 ++++-- crates/vite_js_runtime/src/runtime.rs | 15 +++ docs/guide/env.md | 11 +- 8 files changed, 180 insertions(+), 35 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/.nvmrc create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots/command_env_install_nvmrc.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/.nvmrc b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/.nvmrc new file mode 100644 index 0000000000..2bd5a0a98a --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/package.json new file mode 100644 index 0000000000..0acc764a54 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-env-install-nvmrc" +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml new file mode 100644 index 0000000000..d40f4bb05c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml @@ -0,0 +1,8 @@ +[[case]] +name = "command_env_install_nvmrc" +vp = "global" +skip-platforms = ["windows"] +seed-runtime = false +steps = [ + { argv = ["vp", "env", "install"], comment = "Install version from .nvmrc (22.x)", continue-on-failure = true }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots/command_env_install_nvmrc.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots/command_env_install_nvmrc.md new file mode 100644 index 0000000000..c40f6fae1c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots/command_env_install_nvmrc.md @@ -0,0 +1,12 @@ +# command_env_install_nvmrc + +## `vp env install` + +Install version from .nvmrc (22.x) + +``` +VITE+ - The Unified Toolchain for the Web + +Installing Node.js ... +Installed Node.js +``` diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index b7d5152a47..bc8c1221c6 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -97,10 +97,10 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { // Section: Version Resolution print_section("Version Resolution"); - let resolved_version = check_current_resolution(&cwd, shim_mode, system_node_path).await; + let resolution = check_current_resolution(&cwd, shim_mode, system_node_path).await; // Section: devEngines (conditional, see rfcs/dev-engines.md) - check_dev_engines(&cwd, resolved_version.as_deref()).await; + check_dev_engines(&cwd, resolution.as_ref()).await; // Section: Conflicts (conditional) check_conflicts(); @@ -538,7 +538,7 @@ async fn check_current_resolution( cwd: &AbsolutePathBuf, shim_mode: ShimMode, system_node_path: Option, -) -> Option { +) -> Option { print_check(" ", "Directory", &cwd.as_path().display().to_string()); // In system-first mode, show system Node.js info instead of managed resolution @@ -591,7 +591,7 @@ async fn check_current_resolution( ); print_hint("Version will be downloaded on first use."); } - Some(resolution.version) + Some(resolution) } Err(e) => { print_check( @@ -669,8 +669,8 @@ async fn find_nearest_dev_engines_node_version(cwd: &AbsolutePathBuf) -> Option< /// All checks are semver-aware: an exact version satisfying a declared range is /// not a conflict. Findings are warnings or notes; they never fail the doctor run /// and are never auto-fixed. -async fn check_dev_engines(cwd: &AbsolutePathBuf, resolved_version: Option<&str>) { - let findings = collect_dev_engines_findings(cwd, resolved_version).await; +async fn check_dev_engines(cwd: &AbsolutePathBuf, resolution: Option<&config::VersionResolution>) { + let findings = collect_dev_engines_findings(cwd, resolution).await; if findings.is_empty() { return; } @@ -708,19 +708,52 @@ async fn read_workspace_root_doc( Some((serde_json::from_str(&content).ok()?, serde_json::from_str(&content).ok()?)) } +async fn nvmrc_conflict_finding( + resolution: Option<&config::VersionResolution>, +) -> Option { + let resolution = resolution?; + if !matches!( + resolution.source.as_str(), + ".node-version" | "devEngines.runtime" | "engines.node" + ) { + return None; + } + + let project_root = resolution.project_root.as_ref()?; + let declared = vite_js_runtime::read_nvmrc_file(project_root).await?; + let version = node_semver::Version::parse(&resolution.version).ok()?; + let range = node_semver::Range::parse(declared.as_str()).ok()?; + if range.satisfies(&version) { + return None; + } + + Some(DevEnginesFinding::warn( + "Runtime", + format!( + ".nvmrc \"{declared}\" does not include resolved Node.js {version} from {source}", + source = resolution.source + ), + )) +} + /// Collect the devEngines findings for the nearest package.json. async fn collect_dev_engines_findings( cwd: &AbsolutePathBuf, - resolved_version: Option<&str>, + resolution: Option<&config::VersionResolution>, ) -> Vec { + let mut findings = Vec::new(); + if let Some(finding) = nvmrc_conflict_finding(resolution).await { + findings.push(finding); + } + let Some((pkg_dir, content)) = find_nearest_package_json(cwd).await else { - return Vec::new(); + return findings; }; let Ok(raw) = serde_json::from_str::(&content) else { - return Vec::new(); + return findings; }; let Ok(pkg) = serde_json::from_str::(&content) else { - return Vec::new(); + return findings; }; // Package-manager checks examine the WORKSPACE ROOT package.json: that is the @@ -734,8 +767,6 @@ async fn collect_dev_engines_findings( None => (&raw, &pkg), }; - let mut findings: Vec = Vec::new(); - let runtime_field = pkg.dev_engines.as_ref().and_then(|de| de.runtime.as_ref()); let package_manager_field = pm_pkg.dev_engines.as_ref().and_then(|de| de.package_manager.as_ref()); @@ -762,15 +793,18 @@ async fn collect_dev_engines_findings( } // Resolved Node.js version vs engines.node - if let Some(resolved) = resolved_version + if let Some(resolution) = resolution && let Some(engines_node) = pkg.engines.as_ref().and_then(|e| e.node.as_ref()) - && let Ok(version) = node_semver::Version::parse(resolved) + && let Ok(version) = node_semver::Version::parse(&resolution.version) && let Ok(range) = node_semver::Range::parse(engines_node.as_str()) && !range.satisfies(&version) { findings.push(DevEnginesFinding::warn( "Runtime", - format!("resolved Node.js {resolved} does not satisfy engines.node \"{engines_node}\""), + format!( + "resolved Node.js {} does not satisfy engines.node \"{engines_node}\"", + resolution.version + ), )); } @@ -994,14 +1028,21 @@ mod tests { /// Test helper: write `files` into a temp project and collect devEngines findings. async fn dev_engines_findings_for( files: &[(&str, &str)], - resolved_version: Option<&str>, + resolved: Option<(&str, &str)>, ) -> Vec { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); for (name, content) in files { tokio::fs::write(temp_path.join(*name), content).await.unwrap(); } - collect_dev_engines_findings(&temp_path, resolved_version).await + let resolution = resolved.map(|(version, source)| config::VersionResolution { + version: version.into(), + source: source.into(), + source_path: None, + project_root: Some(temp_path.clone()), + is_range: false, + }); + collect_dev_engines_findings(&temp_path, resolution.as_ref()).await } // npm-install-checks: "semver version is not in range" (via .node-version) @@ -1052,7 +1093,7 @@ mod tests { async fn test_dev_engines_findings_resolved_violates_engines_node() { let findings = dev_engines_findings_for( &[("package.json", r#"{"engines":{"node":">=22.0.0"}}"#)], - Some("20.18.0"), + Some(("20.18.0", "engines.node")), ) .await; @@ -1065,6 +1106,53 @@ mod tests { ); } + #[tokio::test] + async fn test_dev_engines_findings_nvmrc_conflicts_with_dev_engines() { + let findings = dev_engines_findings_for( + &[ + (".nvmrc", "20\n"), + ( + "package.json", + r#"{"devEngines":{"runtime":{"name":"node","version":"^22.0.0"}}}"#, + ), + ], + Some(("22.5.0", "devEngines.runtime")), + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].message.contains(".nvmrc \"20\" does not include")); + } + + #[tokio::test] + async fn test_dev_engines_findings_nvmrc_conflicts_without_package_json() { + let findings = dev_engines_findings_for( + &[(".node-version", "22.5.0\n"), (".nvmrc", "20\n")], + Some(("22.5.0", ".node-version")), + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].message.contains("from .node-version")); + } + + #[tokio::test] + async fn test_dev_engines_findings_nvmrc_satisfies_resolved_version() { + let findings = dev_engines_findings_for( + &[ + (".nvmrc", "22\n"), + ( + "package.json", + r#"{"devEngines":{"runtime":{"name":"node","version":"^22.0.0"}}}"#, + ), + ], + Some(("22.5.0", "devEngines.runtime")), + ) + .await; + + assert!(findings.is_empty(), "findings: {:?}", messages(&findings)); + } + // npm-install-checks: "invalid name" #[tokio::test] async fn test_dev_engines_findings_package_manager_name_mismatch() { @@ -1349,7 +1437,7 @@ mod tests { }"#, ), ], - Some("24.1.0"), + Some(("24.1.0", ".node-version")), ) .await; diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index 119469b4ce..9f418d39ba 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -63,6 +63,18 @@ fn should_print_env_clean_tip(subcommand: &EnvSubcommands) -> bool { } } +fn is_installable_version_source(source: &str) -> bool { + matches!( + source, + ".node-version" + | ".nvmrc" + | "engines.node" + | "devEngines.runtime" + | config::VERSION_ENV_VAR + | config::SESSION_VERSION_FILE + ) +} + /// Execute the env command based on the provided arguments. pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { // Handle subcommands first @@ -125,18 +137,11 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result {} - _ => { - eprintln!("No Node.js version found in current project."); - eprintln!("Specify a version: vp env install "); - eprintln!("Or pin one: vp env pin "); - return Ok(exit_status(1)); - } + if !is_installable_version_source(&resolution.source) { + eprintln!("No Node.js version found in current project."); + eprintln!("Specify a version: vp env install "); + eprintln!("Or pin one: vp env pin "); + return Ok(exit_status(1)); } (resolution.version, from_session_override) }; @@ -201,3 +206,13 @@ async fn print_env(cwd: AbsolutePathBuf) -> Result { Ok(ExitStatus::default()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nvmrc_is_an_installable_version_source() { + assert!(is_installable_version_source(".nvmrc")); + } +} diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 541c10f7bf..b668860bf4 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -1728,4 +1728,19 @@ mod tests { ); assert_eq!(resolution.source, VersionSource::EnginesNode); } + + #[tokio::test] + async fn test_resolve_node_version_child_nvmrc_over_parent_node_version() { + let temp_dir = TempDir::new().unwrap(); + let parent_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write(parent_path.join(".node-version"), "22.0.0\n").await.unwrap(); + + let child_path = parent_path.join("child"); + tokio::fs::create_dir(&child_path).await.unwrap(); + tokio::fs::write(child_path.join(".nvmrc"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_node_version(&child_path, true).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::NvmrcFile); + } } diff --git a/docs/guide/env.md b/docs/guide/env.md index ac2020c38c..a1580348d5 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -6,13 +6,16 @@ Managed mode is on by default, so `node`, `npm`, and related shims resolve through Vite+ and pick the right Node.js version for the current project. -The project Node.js version is resolved from these sources, in priority order: +Vite+ checks the current directory first, then walks up through its parents. The nearest directory +with a supported declaration wins. Within each directory, sources are checked in this order: -1. `.node-version` file (current or parent directories) +1. `.node-version` file 2. `devEngines.runtime` in `package.json` (the [devEngines standard](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines)) 3. `engines.node` in `package.json` -4. `.nvmrc` file (current or parent directories) -5. The global default (`vp env default`), then the latest LTS +4. `.nvmrc` file + +If no directory declares a version, Vite+ uses the global default (`vp env default`) and then the +latest LTS. `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict.