From ff13a96e0b36b399e897b3ed4088ddc78e94678f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Fri, 26 Jun 2026 09:37:50 +0900 Subject: [PATCH 1/5] fix(env): invalidate stale shim cache when project source changes --- crates/vite_global_cli/src/shim/dispatch.rs | 203 +++++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 07659eecd0..20d29b48ae 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1245,14 +1245,16 @@ pub(crate) async fn resolve_with_cache(cwd: &AbsolutePathBuf) -> Result Result Result { + let Some(current) = current_effective_project_source(cwd).await? else { + return Ok(!is_project_version_source(&entry.source)); + }; + + Ok(entry.source == current.0 && entry.source_path.as_deref() == Some(current.1.as_str())) +} + +async fn current_effective_project_source( + cwd: &AbsolutePathBuf, +) -> Result, String> { + let mut current = cwd.clone(); + + loop { + let node_version_path = current.join(".node-version"); + if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) + && let Some(version) = vite_js_runtime::read_node_version_file(¤t).await + { + if is_valid_version_spec(&version) { + return Ok(Some(( + ".node-version".to_string(), + node_version_path.as_path().display().to_string(), + ))); + } + return package_json_effective_source(¤t).await.map(ProjectSource::into_option); + } + + match package_json_effective_source(¤t).await? { + ProjectSource::Found(source) => return Ok(Some(source)), + ProjectSource::Stop => return Ok(None), + ProjectSource::KeepWalking => {} + } + + match current.parent() { + Some(parent) => current = parent.to_absolute_path_buf(), + None => return Ok(None), + } + } +} + +enum ProjectSource { + Found((String, String)), + Stop, + KeepWalking, +} + +impl ProjectSource { + fn into_option(self) -> Option<(String, String)> { + match self { + Self::Found(source) => Some(source), + Self::Stop | Self::KeepWalking => None, + } + } +} + +async fn package_json_effective_source(dir: &AbsolutePathBuf) -> Result { + let path = dir.join("package.json"); + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + return Ok(ProjectSource::KeepWalking); + } + + let Ok(content) = tokio::fs::read_to_string(&path).await else { + return Ok(ProjectSource::KeepWalking); + }; + let Ok(pkg) = serde_json::from_str::(&content) else { + return Ok(ProjectSource::KeepWalking); + }; + let source_path = path.as_path().display().to_string(); + + if let Some(version) = pkg.dev_engines_runtime("node").and_then(|r| r.version.clone()) { + if is_valid_version_spec(&version) { + return Ok(ProjectSource::Found(("devEngines.runtime".to_string(), source_path))); + } + return Ok(pkg + .engines + .as_ref() + .and_then(|e| e.node.clone()) + .and_then(|version| { + is_valid_version_spec(&version) + .then(|| ProjectSource::Found(("engines.node".to_string(), source_path))) + }) + .unwrap_or(ProjectSource::Stop)); + } + + if let Some(version) = pkg.engines.as_ref().and_then(|e| e.node.clone()) { + if is_valid_version_spec(&version) { + return Ok(ProjectSource::Found(("engines.node".to_string(), source_path))); + } + return Ok(ProjectSource::Stop); + } + + Ok(ProjectSource::KeepWalking) +} + +fn is_valid_version_spec(version: &str) -> bool { + vite_js_runtime::is_valid_version(&vite_str::Str::from(version.trim())) +} + +fn is_project_version_source(source: &str) -> bool { + matches!(source, ".node-version" | "devEngines.runtime" | "engines.node") +} + /// Ensure Node.js is installed. pub(crate) async fn ensure_installed(version: &str) -> Result { let home_dir = vite_shared::get_vp_home() @@ -1471,6 +1578,98 @@ mod tests { } } + fn cache_entry(source: &str, source_path: Option<&AbsolutePathBuf>) -> ResolveCacheEntry { + ResolveCacheEntry { + version: "24.18.0".to_string(), + source: source.to_string(), + project_root: None, + resolved_at: cache::now_timestamp(), + version_file_mtime: 0, + source_path: source_path.map(|p| p.as_path().display().to_string()), + is_range: false, + } + } + + #[tokio::test] + async fn test_cached_lts_invalidates_when_dev_engines_is_added() { + let temp = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let entry = cache_entry("lts", None); + + assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); + + std::fs::write( + cwd.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .unwrap(); + + assert!(!cached_project_source_still_current(&cwd, &entry).await.unwrap()); + } + + #[tokio::test] + async fn test_cached_parent_source_invalidates_when_nearer_dev_engines_is_added() { + let temp = TempDir::new().unwrap(); + let parent = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let child = parent.join("child"); + std::fs::create_dir(&child).unwrap(); + let node_version = parent.join(".node-version"); + std::fs::write(&node_version, "24.18.0").unwrap(); + let entry = cache_entry(".node-version", Some(&node_version)); + + assert!(cached_project_source_still_current(&child, &entry).await.unwrap()); + + std::fs::write( + child.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .unwrap(); + + assert!(!cached_project_source_still_current(&child, &entry).await.unwrap()); + } + + #[tokio::test] + async fn test_cached_fallback_source_survives_invalid_higher_priority_source() { + let temp = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + std::fs::write(cwd.join(".node-version"), "not-a-version").unwrap(); + std::fs::write( + cwd.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .unwrap(); + let entry = cache_entry("devEngines.runtime", Some(&cwd.join("package.json"))); + + assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); + } + + #[tokio::test] + async fn test_cached_default_survives_invalid_project_source() { + let temp = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + std::fs::write(cwd.join(".node-version"), "not-a-version").unwrap(); + let entry = cache_entry("lts", None); + + assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); + } + + #[tokio::test] + async fn test_invalid_package_source_does_not_walk_to_parent() { + let temp = TempDir::new().unwrap(); + let parent = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let child = parent.join("child"); + std::fs::create_dir(&child).unwrap(); + std::fs::write(parent.join(".node-version"), "24.18.0").unwrap(); + std::fs::write( + child.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}}}"#, + ) + .unwrap(); + let entry = cache_entry("lts", None); + + assert!(cached_project_source_still_current(&child, &entry).await.unwrap()); + } + #[test] #[serial] fn test_find_system_tool_works_without_bypass() { From 94d1f261d4bcff8b44dd380aac21b0e05a1139ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Fri, 26 Jun 2026 10:41:08 +0900 Subject: [PATCH 2/5] test(env): cover shim cache fallback paths --- crates/vite_global_cli/src/shim/dispatch.rs | 45 +++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 20d29b48ae..bd810e85f3 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1352,9 +1352,7 @@ async fn package_json_effective_source(dir: &AbsolutePathBuf) -> Result(&content) else { return Ok(ProjectSource::KeepWalking); }; @@ -1643,6 +1641,20 @@ mod tests { assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); } + #[tokio::test] + async fn test_cached_engines_source_survives_invalid_dev_engines_runtime() { + let temp = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + std::fs::write( + cwd.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}},"engines":{"node":"22.22.0"}}"#, + ) + .unwrap(); + let entry = cache_entry("engines.node", Some(&cwd.join("package.json"))); + + assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); + } + #[tokio::test] async fn test_cached_default_survives_invalid_project_source() { let temp = TempDir::new().unwrap(); @@ -1653,6 +1665,33 @@ mod tests { assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); } + #[tokio::test] + #[serial] + async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { + let temp = TempDir::new().unwrap(); + let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); + std::fs::create_dir(&cwd).unwrap(); + let _guard = vite_shared::EnvConfig::test_guard( + vite_shared::EnvConfig::for_test_with_home(vp_home.as_path()), + ); + + let mut cache = ResolveCache::default(); + cache.insert(&cwd, cache_entry("lts", None)); + cache.save(&cache::get_cache_path().unwrap()); + + std::fs::write( + cwd.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .unwrap(); + + let resolved = resolve_with_cache(&cwd).await.unwrap(); + + assert_eq!(resolved.version, "22.22.0"); + assert_eq!(resolved.source, "devEngines.runtime"); + } + #[tokio::test] async fn test_invalid_package_source_does_not_walk_to_parent() { let temp = TempDir::new().unwrap(); From 0d7a4c4fdf80d182f685c4043d2c1ec2599c32fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Fri, 26 Jun 2026 10:42:10 +0900 Subject: [PATCH 3/5] fix(env): ignore empty engines node in cache validation --- crates/vite_global_cli/src/shim/dispatch.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index bd810e85f3..9e9218214b 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1374,6 +1374,9 @@ async fn package_json_effective_source(dir: &AbsolutePathBuf) -> Result Date: Fri, 26 Jun 2026 10:45:21 +0900 Subject: [PATCH 4/5] refactor(env): simplify shim cache validation --- crates/vite_global_cli/src/shim/dispatch.rs | 23 +++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 9e9218214b..35fefbdc91 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1293,7 +1293,10 @@ async fn cached_project_source_still_current( entry: &ResolveCacheEntry, ) -> Result { let Some(current) = current_effective_project_source(cwd).await? else { - return Ok(!is_project_version_source(&entry.source)); + return Ok(!matches!( + entry.source.as_str(), + ".node-version" | "devEngines.runtime" | "engines.node" + )); }; Ok(entry.source == current.0 && entry.source_path.as_deref() == Some(current.1.as_str())) @@ -1315,7 +1318,10 @@ async fn current_effective_project_source( node_version_path.as_path().display().to_string(), ))); } - return package_json_effective_source(¤t).await.map(ProjectSource::into_option); + return Ok(match package_json_effective_source(¤t).await? { + ProjectSource::Found(source) => Some(source), + ProjectSource::Stop | ProjectSource::KeepWalking => None, + }); } match package_json_effective_source(¤t).await? { @@ -1337,15 +1343,6 @@ enum ProjectSource { KeepWalking, } -impl ProjectSource { - fn into_option(self) -> Option<(String, String)> { - match self { - Self::Found(source) => Some(source), - Self::Stop | Self::KeepWalking => None, - } - } -} - async fn package_json_effective_source(dir: &AbsolutePathBuf) -> Result { let path = dir.join("package.json"); if !tokio::fs::try_exists(&path).await.unwrap_or(false) { @@ -1390,10 +1387,6 @@ fn is_valid_version_spec(version: &str) -> bool { vite_js_runtime::is_valid_version(&vite_str::Str::from(version.trim())) } -fn is_project_version_source(source: &str) -> bool { - matches!(source, ".node-version" | "devEngines.runtime" | "engines.node") -} - /// Ensure Node.js is installed. pub(crate) async fn ensure_installed(version: &str) -> Result { let home_dir = vite_shared::get_vp_home() From 84ce76c772f916b8e98dd1ef1ddaaa41b8966d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Fri, 26 Jun 2026 10:51:40 +0900 Subject: [PATCH 5/5] refactor(env): share project source resolution --- .../src/commands/env/config.rs | 305 +++++++++++++----- crates/vite_global_cli/src/shim/dispatch.rs | 201 +----------- 2 files changed, 232 insertions(+), 274 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index 940a9cc7c3..b686837cfe 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize}; use vite_js_runtime::{ - NodeProvider, VersionSource, normalize_version, read_package_json, resolve_node_version, + NodeProvider, VersionSource, is_valid_version, normalize_version, read_package_json, + resolve_node_version, }; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -228,91 +229,123 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result Result { - let provider = NodeProvider::new(); +pub(crate) struct ProjectVersionSource { + pub version: String, + pub source: String, + pub source_path: AbsolutePathBuf, + pub project_root: AbsolutePathBuf, +} - // Use shared version resolution with directory walking - let resolution = resolve_node_version(cwd, true) +/// Resolve the effective project-file Node.js version source. +/// +/// `warn_invalid` controls whether invalid version specs print user-facing +/// warnings. Use `true` for env commands, and `false` for shim cache validation +/// so wrapped tool output stays quiet. +pub(crate) async fn resolve_project_version_source( + cwd: &AbsolutePath, + warn_invalid: bool, +) -> Result, Error> { + let Some(resolution) = resolve_node_version(cwd, true) .await - .map_err(|e| Error::ConfigError(e.to_string().into()))?; + .map_err(|e| Error::ConfigError(e.to_string().into()))? + else { + return Ok(None); + }; - if let Some(resolution) = resolution { - // Validate version before attempting resolution - // If invalid, warning is printed by normalize_version and we fall through to defaults - if let Some(validated) = - normalize_version(&resolution.version.clone().into(), &resolution.source.to_string()) + if let Some(version) = + validate_version_spec(&resolution.version, &resolution.source.to_string(), warn_invalid) + { + if let (Some(source_path), Some(project_root)) = + (resolution.source_path, resolution.project_root) { - // Detect if the original version spec was a range (not exact) - // This includes partial versions (20, 20.18), semver ranges (^20.0.0), LTS aliases, and "latest" - let is_range = NodeProvider::is_version_alias(&validated) - || !NodeProvider::is_exact_version(&validated); - - let resolved = resolve_version_string(&validated, &provider).await?; - return Ok(VersionResolution { - version: resolved, + return Ok(Some(ProjectVersionSource { + version, source: resolution.source.to_string(), - source_path: resolution.source_path, - project_root: resolution.project_root, - is_range, - }); + source_path, + project_root, + })); } + return Ok(None); + } - // 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 devEngines.runtime, then engines.node - // - DevEnginesRuntime: try engines.node - if matches!( - resolution.source, - VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime - ) { - 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 devEngines.runtime (only when falling back from .node-version) - if matches!(resolution.source, VersionSource::NodeVersionFile) { - if let Some(dev_engines) = pkg - .dev_engines_runtime("node") - .and_then(|r| r.version.clone()) - .and_then(|v| normalize_version(&v, "devEngines.runtime")) - { - let resolved = resolve_version_string(&dev_engines, &provider).await?; - let is_range = NodeProvider::is_lts_alias(&dev_engines) - || !NodeProvider::is_exact_version(&dev_engines); - return Ok(VersionResolution { - version: resolved, - source: "devEngines.runtime".into(), - source_path: Some(package_json_path), - project_root: Some(project_root.clone()), - is_range, - }); - } - } - - // 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, - }); - } - } - } - } - // Invalid version and no valid package.json sources - fall through to user default or LTS + // 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 + ) { + 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); + }; + + if matches!(resolution.source, VersionSource::NodeVersionFile) + && let Some(version) = pkg + .dev_engines_runtime("node") + .and_then(|r| r.version.clone()) + .and_then(|v| validate_version_spec(&v, "devEngines.runtime", warn_invalid)) + { + return Ok(Some(ProjectVersionSource { + version, + source: "devEngines.runtime".into(), + source_path: package_json_path, + project_root, + })); + } + + if let Some(version) = pkg + .engines + .as_ref() + .and_then(|e| e.node.clone()) + .and_then(|v| validate_version_spec(&v, "engines.node", warn_invalid)) + { + return Ok(Some(ProjectVersionSource { + version, + source: "engines.node".into(), + source_path: package_json_path, + project_root, + })); + } + + Ok(None) +} + +fn validate_version_spec( + version: &vite_str::Str, + source: &str, + warn_invalid: bool, +) -> Option { + if warn_invalid { + normalize_version(version, source).map(|v| v.to_string()) + } else { + let trimmed = version.trim(); + is_valid_version(trimmed).then(|| trimmed.to_string()) + } +} + +/// Resolve Node.js version from project files only (skipping session overrides). +/// +/// This is used by `vp env use` without arguments to revert to file-based resolution. +pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result { + let provider = NodeProvider::new(); + + if let Some(project_source) = resolve_project_version_source(cwd, true).await? { + let is_range = NodeProvider::is_version_alias(&project_source.version) + || !NodeProvider::is_exact_version(&project_source.version); + let resolved = resolve_version_string(&project_source.version, &provider).await?; + return Ok(VersionResolution { + version: resolved, + source: project_source.source, + source_path: Some(project_source.source_path), + project_root: Some(project_source.project_root), + is_range, + }); } // CLI-specific: Check user default from config @@ -779,6 +812,120 @@ mod tests { ); } + #[tokio::test] + async fn test_project_source_detects_new_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none()); + + tokio::fs::write( + temp_path.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .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, "devEngines.runtime"); + assert_eq!(source.source_path, temp_path.join("package.json")); + } + + #[tokio::test] + async fn test_project_source_prefers_nearer_dev_engines_over_parent_node_version() { + 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(".node-version"), "24.18.0").await.unwrap(); + tokio::fs::write( + child.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .await + .unwrap(); + + let source = resolve_project_version_source(&child, false).await.unwrap().unwrap(); + assert_eq!(source.version, "22.22.0"); + assert_eq!(source.source, "devEngines.runtime"); + assert_eq!(source.source_path, child.join("package.json")); + } + + #[tokio::test] + async fn test_project_source_falls_back_from_invalid_node_version_to_dev_engines() { + 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").await.unwrap(); + tokio::fs::write( + temp_path.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .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, "devEngines.runtime"); + } + + #[tokio::test] + async fn test_project_source_falls_back_from_invalid_dev_engines_to_engines_node() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + temp_path.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}},"engines":{"node":"22.22.0"}}"#, + ) + .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, "engines.node"); + } + + #[tokio::test] + async fn test_project_source_ignores_empty_engines_node_and_keeps_walking() { + 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(".node-version"), "24.18.0").await.unwrap(); + tokio::fs::write(child.join("package.json"), r#"{"engines":{"node":""}}"#).await.unwrap(); + + let source = resolve_project_version_source(&child, false).await.unwrap().unwrap(); + assert_eq!(source.version, "24.18.0"); + assert_eq!(source.source, ".node-version"); + assert_eq!(source.source_path, parent.join(".node-version")); + } + + #[tokio::test] + async fn test_project_source_stops_at_invalid_package_source() { + 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(".node-version"), "24.18.0").await.unwrap(); + tokio::fs::write( + child.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}}}"#, + ) + .await + .unwrap(); + + assert!(resolve_project_version_source(&child, false).await.unwrap().is_none()); + } + + #[tokio::test] + async fn test_project_source_returns_none_for_invalid_project_source() { + 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").await.unwrap(); + + assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none()); + } + #[tokio::test] async fn test_resolve_version_latest_alias_in_node_version() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 35fefbdc91..5c4fecfa89 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -1292,99 +1292,18 @@ async fn cached_project_source_still_current( cwd: &AbsolutePathBuf, entry: &ResolveCacheEntry, ) -> Result { - let Some(current) = current_effective_project_source(cwd).await? else { + let Some(current) = + config::resolve_project_version_source(cwd, false).await.map_err(|e| format!("{e}"))? + else { return Ok(!matches!( entry.source.as_str(), ".node-version" | "devEngines.runtime" | "engines.node" )); }; - Ok(entry.source == current.0 && entry.source_path.as_deref() == Some(current.1.as_str())) -} - -async fn current_effective_project_source( - cwd: &AbsolutePathBuf, -) -> Result, String> { - let mut current = cwd.clone(); - - loop { - let node_version_path = current.join(".node-version"); - if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) - && let Some(version) = vite_js_runtime::read_node_version_file(¤t).await - { - if is_valid_version_spec(&version) { - return Ok(Some(( - ".node-version".to_string(), - node_version_path.as_path().display().to_string(), - ))); - } - return Ok(match package_json_effective_source(¤t).await? { - ProjectSource::Found(source) => Some(source), - ProjectSource::Stop | ProjectSource::KeepWalking => None, - }); - } - - match package_json_effective_source(¤t).await? { - ProjectSource::Found(source) => return Ok(Some(source)), - ProjectSource::Stop => return Ok(None), - ProjectSource::KeepWalking => {} - } - - match current.parent() { - Some(parent) => current = parent.to_absolute_path_buf(), - None => return Ok(None), - } - } -} - -enum ProjectSource { - Found((String, String)), - Stop, - KeepWalking, -} - -async fn package_json_effective_source(dir: &AbsolutePathBuf) -> Result { - let path = dir.join("package.json"); - if !tokio::fs::try_exists(&path).await.unwrap_or(false) { - return Ok(ProjectSource::KeepWalking); - } - - let content = tokio::fs::read_to_string(&path).await.map_err(|e| format!("{e}"))?; - let Ok(pkg) = serde_json::from_str::(&content) else { - return Ok(ProjectSource::KeepWalking); - }; - let source_path = path.as_path().display().to_string(); - - if let Some(version) = pkg.dev_engines_runtime("node").and_then(|r| r.version.clone()) { - if is_valid_version_spec(&version) { - return Ok(ProjectSource::Found(("devEngines.runtime".to_string(), source_path))); - } - return Ok(pkg - .engines - .as_ref() - .and_then(|e| e.node.clone()) - .and_then(|version| { - is_valid_version_spec(&version) - .then(|| ProjectSource::Found(("engines.node".to_string(), source_path))) - }) - .unwrap_or(ProjectSource::Stop)); - } - - if let Some(version) = pkg.engines.as_ref().and_then(|e| e.node.clone()) { - if version.trim().is_empty() { - return Ok(ProjectSource::KeepWalking); - } - if is_valid_version_spec(&version) { - return Ok(ProjectSource::Found(("engines.node".to_string(), source_path))); - } - return Ok(ProjectSource::Stop); - } - - Ok(ProjectSource::KeepWalking) -} - -fn is_valid_version_spec(version: &str) -> bool { - vite_js_runtime::is_valid_version(&vite_str::Str::from(version.trim())) + let current_source_path = current.source_path.as_path().display().to_string(); + Ok(entry.source == current.source + && entry.source_path.as_deref() == Some(current_source_path.as_str())) } /// Ensure Node.js is installed. @@ -1584,83 +1503,6 @@ mod tests { } } - #[tokio::test] - async fn test_cached_lts_invalidates_when_dev_engines_is_added() { - let temp = TempDir::new().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let entry = cache_entry("lts", None); - - assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); - - std::fs::write( - cwd.join("package.json"), - r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, - ) - .unwrap(); - - assert!(!cached_project_source_still_current(&cwd, &entry).await.unwrap()); - } - - #[tokio::test] - async fn test_cached_parent_source_invalidates_when_nearer_dev_engines_is_added() { - let temp = TempDir::new().unwrap(); - let parent = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let child = parent.join("child"); - std::fs::create_dir(&child).unwrap(); - let node_version = parent.join(".node-version"); - std::fs::write(&node_version, "24.18.0").unwrap(); - let entry = cache_entry(".node-version", Some(&node_version)); - - assert!(cached_project_source_still_current(&child, &entry).await.unwrap()); - - std::fs::write( - child.join("package.json"), - r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, - ) - .unwrap(); - - assert!(!cached_project_source_still_current(&child, &entry).await.unwrap()); - } - - #[tokio::test] - async fn test_cached_fallback_source_survives_invalid_higher_priority_source() { - let temp = TempDir::new().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - std::fs::write(cwd.join(".node-version"), "not-a-version").unwrap(); - std::fs::write( - cwd.join("package.json"), - r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, - ) - .unwrap(); - let entry = cache_entry("devEngines.runtime", Some(&cwd.join("package.json"))); - - assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); - } - - #[tokio::test] - async fn test_cached_engines_source_survives_invalid_dev_engines_runtime() { - let temp = TempDir::new().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - std::fs::write( - cwd.join("package.json"), - r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}},"engines":{"node":"22.22.0"}}"#, - ) - .unwrap(); - let entry = cache_entry("engines.node", Some(&cwd.join("package.json"))); - - assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); - } - - #[tokio::test] - async fn test_cached_default_survives_invalid_project_source() { - let temp = TempDir::new().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - std::fs::write(cwd.join(".node-version"), "not-a-version").unwrap(); - let entry = cache_entry("lts", None); - - assert!(cached_project_source_still_current(&cwd, &entry).await.unwrap()); - } - #[tokio::test] #[serial] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { @@ -1688,37 +1530,6 @@ mod tests { assert_eq!(resolved.source, "devEngines.runtime"); } - #[tokio::test] - async fn test_empty_engines_node_keeps_parent_source_current() { - let temp = TempDir::new().unwrap(); - let parent = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let child = parent.join("child"); - std::fs::create_dir(&child).unwrap(); - let node_version = parent.join(".node-version"); - std::fs::write(&node_version, "24.18.0").unwrap(); - std::fs::write(child.join("package.json"), r#"{"engines":{"node":""}}"#).unwrap(); - let entry = cache_entry(".node-version", Some(&node_version)); - - assert!(cached_project_source_still_current(&child, &entry).await.unwrap()); - } - - #[tokio::test] - async fn test_invalid_package_source_does_not_walk_to_parent() { - let temp = TempDir::new().unwrap(); - let parent = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let child = parent.join("child"); - std::fs::create_dir(&child).unwrap(); - std::fs::write(parent.join(".node-version"), "24.18.0").unwrap(); - std::fs::write( - child.join("package.json"), - r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}}}"#, - ) - .unwrap(); - let entry = cache_entry("lts", None); - - assert!(cached_project_source_still_current(&child, &entry).await.unwrap()); - } - #[test] #[serial] fn test_find_system_tool_works_without_bypass() {