Skip to content
Merged
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
69 changes: 49 additions & 20 deletions crates/vite_global_cli/src/commands/env/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,29 +258,34 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Er
});
}

// Invalid .node-version - check package.json sources in the same directory
// This mirrors the fallback logic in download_runtime_for_project()
if matches!(resolution.source, VersionSource::NodeVersionFile) {
// Invalid version from a project source - try lower-priority sources in the same directory.
// This mirrors the fallback logic in download_runtime_for_project().
// - NodeVersionFile: try engines.node, then devEngines.runtime
// - EnginesNode: try devEngines.runtime
if matches!(resolution.source, VersionSource::NodeVersionFile | VersionSource::EnginesNode)
{
if let Some(project_root) = &resolution.project_root {
let package_json_path = project_root.join("package.json");
if let Ok(Some(pkg)) = read_package_json(&package_json_path).await {
// Try engines.node
if let Some(engines_node) = pkg
.engines
.as_ref()
.and_then(|e| e.node.clone())
.and_then(|v| normalize_version(&v, "engines.node"))
{
let resolved = resolve_version_string(&engines_node, &provider).await?;
let is_range = NodeProvider::is_lts_alias(&engines_node)
|| !NodeProvider::is_exact_version(&engines_node);
return Ok(VersionResolution {
version: resolved,
source: "engines.node".into(),
source_path: Some(package_json_path),
project_root: Some(project_root.clone()),
is_range,
});
// Try engines.node (only when falling back from .node-version)
if matches!(resolution.source, VersionSource::NodeVersionFile) {
if let Some(engines_node) = pkg
.engines
.as_ref()
.and_then(|e| e.node.clone())
.and_then(|v| normalize_version(&v, "engines.node"))
{
let resolved = resolve_version_string(&engines_node, &provider).await?;
let is_range = NodeProvider::is_lts_alias(&engines_node)
|| !NodeProvider::is_exact_version(&engines_node);
return Ok(VersionResolution {
version: resolved,
source: "engines.node".into(),
source_path: Some(package_json_path),
project_root: Some(project_root.clone()),
is_range,
});
}
}

// Try devEngines.runtime
Expand Down Expand Up @@ -751,6 +756,30 @@ mod tests {
);
}

#[tokio::test]
async fn test_resolve_version_invalid_engines_node_falls_through_to_dev_engines() {
let temp_dir = TempDir::new().unwrap();
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
let _guard = vite_shared::EnvConfig::test_guard(
vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),
);

// Create package.json with invalid engines.node but valid devEngines.runtime
// No .node-version file — resolve_node_version returns EnginesNode source
let package_json = r#"{"engines":{"node":"invalid"},"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#;
tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap();

// resolve_version should fall through from invalid engines.node to devEngines.runtime
let resolution = resolve_version(&temp_path).await.unwrap();

assert_eq!(resolution.source, "devEngines.runtime");
assert!(
resolution.version.starts_with("20."),
"Expected version to start with '20.', got: {}",
resolution.version
);
}

#[tokio::test]
async fn test_resolve_version_latest_alias_in_node_version() {
let temp_dir = TempDir::new().unwrap();
Expand Down
95 changes: 88 additions & 7 deletions crates/vite_global_cli/src/js_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
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, 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};

use crate::error::Error;
use crate::{commands::env::config, error::Error};

/// JavaScript executor using managed Node.js runtime.
///
Expand Down Expand Up @@ -134,15 +137,52 @@ 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.
/// 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 runtime = download_runtime_for_project(project_path).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 = 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 *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 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?
};
self.project_runtime = Some(runtime);
}
Ok(self.project_runtime.as_ref().unwrap())
Expand All @@ -163,8 +203,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
Expand Down Expand Up @@ -252,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<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
.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;
Expand Down
4 changes: 2 additions & 2 deletions crates/vite_js_runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Loading
Loading