Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20.18.0
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -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 },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# command_env_which_nvmrc

## `vp env exec node --version`

Ensure Node.js is installed first

```
<version>
```

## `vp env which node`

Core tool - shows resolved Node.js binary path from .nvmrc

```
VITE+ - The Unified Toolchain for the Web
<home>/.vite-plus/js_runtime/node/<version>/bin/node
Version: 20.18.0
Source: <workspace>/.nvmrc
```
4 changes: 2 additions & 2 deletions crates/vite_global_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},

Expand All @@ -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<String>,

/// Remove session override (revert to file-based resolution)
Expand Down
93 changes: 79 additions & 14 deletions crates/vite_global_cli/src/commands/env/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<VersionResolution, Error> {
// Session override via environment variable (set by `vp env use`)
if let Some(env_version) = vite_shared::EnvConfig::get().node_version {
Expand Down Expand Up @@ -270,24 +271,20 @@ 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);
}

let Some(project_root) = resolution.project_root else {
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))
{
Expand All @@ -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))
{
Expand All @@ -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)
}

Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 4 additions & 34 deletions crates/vite_global_cli/src/js_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<bool, vite_js_runtime::Error> {
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<bool, Error> {
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`).
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/shim/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
));
};

Expand Down
59 changes: 56 additions & 3 deletions crates/vite_js_runtime/src/dev_engines.rs
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down Expand Up @@ -57,6 +57,31 @@ pub async fn read_node_version_file(project_path: &AbsolutePath) -> Option<Str>
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<Str> {
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.
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_js_runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Loading
Loading