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 163c2ded7a..724806855e 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 3c2c6b92ae..b0de3bb0e2 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}; @@ -151,7 +148,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 @@ -452,35 +449,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