|
7 | 7 |
|
8 | 8 | use serde::{Deserialize, Serialize}; |
9 | 9 | use vite_js_runtime::{ |
10 | | - NodeProvider, VersionSource, normalize_version, read_package_json, resolve_node_version, |
| 10 | + NodeProvider, VersionSource, is_valid_version, normalize_version, read_package_json, |
| 11 | + resolve_node_version, |
11 | 12 | }; |
12 | 13 | use vite_path::{AbsolutePath, AbsolutePathBuf}; |
13 | 14 |
|
@@ -228,91 +229,123 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Er |
228 | 229 | resolve_version_from_files(cwd).await |
229 | 230 | } |
230 | 231 |
|
231 | | -/// Resolve Node.js version from project files only (skipping session overrides). |
232 | | -/// |
233 | | -/// This is used by `vp env use` without arguments to revert to file-based resolution. |
234 | | -pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> { |
235 | | - let provider = NodeProvider::new(); |
| 232 | +pub(crate) struct ProjectVersionSource { |
| 233 | + pub version: String, |
| 234 | + pub source: String, |
| 235 | + pub source_path: AbsolutePathBuf, |
| 236 | + pub project_root: AbsolutePathBuf, |
| 237 | +} |
236 | 238 |
|
237 | | - // Use shared version resolution with directory walking |
238 | | - let resolution = resolve_node_version(cwd, true) |
| 239 | +/// Resolve the effective project-file Node.js version source. |
| 240 | +/// |
| 241 | +/// `warn_invalid` controls whether invalid version specs print user-facing |
| 242 | +/// warnings. Use `true` for env commands, and `false` for shim cache validation |
| 243 | +/// so wrapped tool output stays quiet. |
| 244 | +pub(crate) async fn resolve_project_version_source( |
| 245 | + cwd: &AbsolutePath, |
| 246 | + warn_invalid: bool, |
| 247 | +) -> Result<Option<ProjectVersionSource>, Error> { |
| 248 | + let Some(resolution) = resolve_node_version(cwd, true) |
239 | 249 | .await |
240 | | - .map_err(|e| Error::ConfigError(e.to_string().into()))?; |
| 250 | + .map_err(|e| Error::ConfigError(e.to_string().into()))? |
| 251 | + else { |
| 252 | + return Ok(None); |
| 253 | + }; |
241 | 254 |
|
242 | | - if let Some(resolution) = resolution { |
243 | | - // Validate version before attempting resolution |
244 | | - // If invalid, warning is printed by normalize_version and we fall through to defaults |
245 | | - if let Some(validated) = |
246 | | - normalize_version(&resolution.version.clone().into(), &resolution.source.to_string()) |
| 255 | + if let Some(version) = |
| 256 | + validate_version_spec(&resolution.version, &resolution.source.to_string(), warn_invalid) |
| 257 | + { |
| 258 | + if let (Some(source_path), Some(project_root)) = |
| 259 | + (resolution.source_path, resolution.project_root) |
247 | 260 | { |
248 | | - // Detect if the original version spec was a range (not exact) |
249 | | - // This includes partial versions (20, 20.18), semver ranges (^20.0.0), LTS aliases, and "latest" |
250 | | - let is_range = NodeProvider::is_version_alias(&validated) |
251 | | - || !NodeProvider::is_exact_version(&validated); |
252 | | - |
253 | | - let resolved = resolve_version_string(&validated, &provider).await?; |
254 | | - return Ok(VersionResolution { |
255 | | - version: resolved, |
| 261 | + return Ok(Some(ProjectVersionSource { |
| 262 | + version, |
256 | 263 | source: resolution.source.to_string(), |
257 | | - source_path: resolution.source_path, |
258 | | - project_root: resolution.project_root, |
259 | | - is_range, |
260 | | - }); |
| 264 | + source_path, |
| 265 | + project_root, |
| 266 | + })); |
261 | 267 | } |
| 268 | + return Ok(None); |
| 269 | + } |
262 | 270 |
|
263 | | - // Invalid version from a project source - try lower-priority sources in the same directory. |
264 | | - // This mirrors the fallback logic in download_runtime_for_project(). |
265 | | - // - NodeVersionFile: try devEngines.runtime, then engines.node |
266 | | - // - DevEnginesRuntime: try engines.node |
267 | | - if matches!( |
268 | | - resolution.source, |
269 | | - VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime |
270 | | - ) { |
271 | | - if let Some(project_root) = &resolution.project_root { |
272 | | - let package_json_path = project_root.join("package.json"); |
273 | | - if let Ok(Some(pkg)) = read_package_json(&package_json_path).await { |
274 | | - // Try devEngines.runtime (only when falling back from .node-version) |
275 | | - if matches!(resolution.source, VersionSource::NodeVersionFile) { |
276 | | - if let Some(dev_engines) = pkg |
277 | | - .dev_engines_runtime("node") |
278 | | - .and_then(|r| r.version.clone()) |
279 | | - .and_then(|v| normalize_version(&v, "devEngines.runtime")) |
280 | | - { |
281 | | - let resolved = resolve_version_string(&dev_engines, &provider).await?; |
282 | | - let is_range = NodeProvider::is_lts_alias(&dev_engines) |
283 | | - || !NodeProvider::is_exact_version(&dev_engines); |
284 | | - return Ok(VersionResolution { |
285 | | - version: resolved, |
286 | | - source: "devEngines.runtime".into(), |
287 | | - source_path: Some(package_json_path), |
288 | | - project_root: Some(project_root.clone()), |
289 | | - is_range, |
290 | | - }); |
291 | | - } |
292 | | - } |
293 | | - |
294 | | - // Try engines.node |
295 | | - if let Some(engines_node) = pkg |
296 | | - .engines |
297 | | - .as_ref() |
298 | | - .and_then(|e| e.node.clone()) |
299 | | - .and_then(|v| normalize_version(&v, "engines.node")) |
300 | | - { |
301 | | - let resolved = resolve_version_string(&engines_node, &provider).await?; |
302 | | - let is_range = NodeProvider::is_lts_alias(&engines_node) |
303 | | - || !NodeProvider::is_exact_version(&engines_node); |
304 | | - return Ok(VersionResolution { |
305 | | - version: resolved, |
306 | | - source: "engines.node".into(), |
307 | | - source_path: Some(package_json_path), |
308 | | - project_root: Some(project_root.clone()), |
309 | | - is_range, |
310 | | - }); |
311 | | - } |
312 | | - } |
313 | | - } |
314 | | - } |
315 | | - // Invalid version and no valid package.json sources - fall through to user default or LTS |
| 271 | + // Invalid version from a project source: try lower-priority sources in the same directory. |
| 272 | + // This mirrors the fallback logic in download_runtime_for_project(). |
| 273 | + if !matches!( |
| 274 | + resolution.source, |
| 275 | + VersionSource::NodeVersionFile | VersionSource::DevEnginesRuntime |
| 276 | + ) { |
| 277 | + return Ok(None); |
| 278 | + } |
| 279 | + |
| 280 | + let Some(project_root) = resolution.project_root else { |
| 281 | + return Ok(None); |
| 282 | + }; |
| 283 | + let package_json_path = project_root.join("package.json"); |
| 284 | + let Ok(Some(pkg)) = read_package_json(&package_json_path).await else { |
| 285 | + return Ok(None); |
| 286 | + }; |
| 287 | + |
| 288 | + if matches!(resolution.source, VersionSource::NodeVersionFile) |
| 289 | + && let Some(version) = pkg |
| 290 | + .dev_engines_runtime("node") |
| 291 | + .and_then(|r| r.version.clone()) |
| 292 | + .and_then(|v| validate_version_spec(&v, "devEngines.runtime", warn_invalid)) |
| 293 | + { |
| 294 | + return Ok(Some(ProjectVersionSource { |
| 295 | + version, |
| 296 | + source: "devEngines.runtime".into(), |
| 297 | + source_path: package_json_path, |
| 298 | + project_root, |
| 299 | + })); |
| 300 | + } |
| 301 | + |
| 302 | + if let Some(version) = pkg |
| 303 | + .engines |
| 304 | + .as_ref() |
| 305 | + .and_then(|e| e.node.clone()) |
| 306 | + .and_then(|v| validate_version_spec(&v, "engines.node", warn_invalid)) |
| 307 | + { |
| 308 | + return Ok(Some(ProjectVersionSource { |
| 309 | + version, |
| 310 | + source: "engines.node".into(), |
| 311 | + source_path: package_json_path, |
| 312 | + project_root, |
| 313 | + })); |
| 314 | + } |
| 315 | + |
| 316 | + Ok(None) |
| 317 | +} |
| 318 | + |
| 319 | +fn validate_version_spec( |
| 320 | + version: &vite_str::Str, |
| 321 | + source: &str, |
| 322 | + warn_invalid: bool, |
| 323 | +) -> Option<String> { |
| 324 | + if warn_invalid { |
| 325 | + normalize_version(version, source).map(|v| v.to_string()) |
| 326 | + } else { |
| 327 | + let trimmed = version.trim(); |
| 328 | + is_valid_version(trimmed).then(|| trimmed.to_string()) |
| 329 | + } |
| 330 | +} |
| 331 | + |
| 332 | +/// Resolve Node.js version from project files only (skipping session overrides). |
| 333 | +/// |
| 334 | +/// This is used by `vp env use` without arguments to revert to file-based resolution. |
| 335 | +pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result<VersionResolution, Error> { |
| 336 | + let provider = NodeProvider::new(); |
| 337 | + |
| 338 | + if let Some(project_source) = resolve_project_version_source(cwd, true).await? { |
| 339 | + let is_range = NodeProvider::is_version_alias(&project_source.version) |
| 340 | + || !NodeProvider::is_exact_version(&project_source.version); |
| 341 | + let resolved = resolve_version_string(&project_source.version, &provider).await?; |
| 342 | + return Ok(VersionResolution { |
| 343 | + version: resolved, |
| 344 | + source: project_source.source, |
| 345 | + source_path: Some(project_source.source_path), |
| 346 | + project_root: Some(project_source.project_root), |
| 347 | + is_range, |
| 348 | + }); |
316 | 349 | } |
317 | 350 |
|
318 | 351 | // CLI-specific: Check user default from config |
@@ -779,6 +812,120 @@ mod tests { |
779 | 812 | ); |
780 | 813 | } |
781 | 814 |
|
| 815 | + #[tokio::test] |
| 816 | + async fn test_project_source_detects_new_dev_engines() { |
| 817 | + let temp_dir = TempDir::new().unwrap(); |
| 818 | + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 819 | + |
| 820 | + assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none()); |
| 821 | + |
| 822 | + tokio::fs::write( |
| 823 | + temp_path.join("package.json"), |
| 824 | + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, |
| 825 | + ) |
| 826 | + .await |
| 827 | + .unwrap(); |
| 828 | + |
| 829 | + let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap(); |
| 830 | + assert_eq!(source.version, "22.22.0"); |
| 831 | + assert_eq!(source.source, "devEngines.runtime"); |
| 832 | + assert_eq!(source.source_path, temp_path.join("package.json")); |
| 833 | + } |
| 834 | + |
| 835 | + #[tokio::test] |
| 836 | + async fn test_project_source_prefers_nearer_dev_engines_over_parent_node_version() { |
| 837 | + let temp_dir = TempDir::new().unwrap(); |
| 838 | + let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 839 | + let child = parent.join("child"); |
| 840 | + tokio::fs::create_dir(&child).await.unwrap(); |
| 841 | + tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap(); |
| 842 | + tokio::fs::write( |
| 843 | + child.join("package.json"), |
| 844 | + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, |
| 845 | + ) |
| 846 | + .await |
| 847 | + .unwrap(); |
| 848 | + |
| 849 | + let source = resolve_project_version_source(&child, false).await.unwrap().unwrap(); |
| 850 | + assert_eq!(source.version, "22.22.0"); |
| 851 | + assert_eq!(source.source, "devEngines.runtime"); |
| 852 | + assert_eq!(source.source_path, child.join("package.json")); |
| 853 | + } |
| 854 | + |
| 855 | + #[tokio::test] |
| 856 | + async fn test_project_source_falls_back_from_invalid_node_version_to_dev_engines() { |
| 857 | + let temp_dir = TempDir::new().unwrap(); |
| 858 | + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 859 | + tokio::fs::write(temp_path.join(".node-version"), "not-a-version").await.unwrap(); |
| 860 | + tokio::fs::write( |
| 861 | + temp_path.join("package.json"), |
| 862 | + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, |
| 863 | + ) |
| 864 | + .await |
| 865 | + .unwrap(); |
| 866 | + |
| 867 | + let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap(); |
| 868 | + assert_eq!(source.version, "22.22.0"); |
| 869 | + assert_eq!(source.source, "devEngines.runtime"); |
| 870 | + } |
| 871 | + |
| 872 | + #[tokio::test] |
| 873 | + async fn test_project_source_falls_back_from_invalid_dev_engines_to_engines_node() { |
| 874 | + let temp_dir = TempDir::new().unwrap(); |
| 875 | + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 876 | + tokio::fs::write( |
| 877 | + temp_path.join("package.json"), |
| 878 | + r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}},"engines":{"node":"22.22.0"}}"#, |
| 879 | + ) |
| 880 | + .await |
| 881 | + .unwrap(); |
| 882 | + |
| 883 | + let source = resolve_project_version_source(&temp_path, false).await.unwrap().unwrap(); |
| 884 | + assert_eq!(source.version, "22.22.0"); |
| 885 | + assert_eq!(source.source, "engines.node"); |
| 886 | + } |
| 887 | + |
| 888 | + #[tokio::test] |
| 889 | + async fn test_project_source_ignores_empty_engines_node_and_keeps_walking() { |
| 890 | + let temp_dir = TempDir::new().unwrap(); |
| 891 | + let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 892 | + let child = parent.join("child"); |
| 893 | + tokio::fs::create_dir(&child).await.unwrap(); |
| 894 | + tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap(); |
| 895 | + tokio::fs::write(child.join("package.json"), r#"{"engines":{"node":""}}"#).await.unwrap(); |
| 896 | + |
| 897 | + let source = resolve_project_version_source(&child, false).await.unwrap().unwrap(); |
| 898 | + assert_eq!(source.version, "24.18.0"); |
| 899 | + assert_eq!(source.source, ".node-version"); |
| 900 | + assert_eq!(source.source_path, parent.join(".node-version")); |
| 901 | + } |
| 902 | + |
| 903 | + #[tokio::test] |
| 904 | + async fn test_project_source_stops_at_invalid_package_source() { |
| 905 | + let temp_dir = TempDir::new().unwrap(); |
| 906 | + let parent = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 907 | + let child = parent.join("child"); |
| 908 | + tokio::fs::create_dir(&child).await.unwrap(); |
| 909 | + tokio::fs::write(parent.join(".node-version"), "24.18.0").await.unwrap(); |
| 910 | + tokio::fs::write( |
| 911 | + child.join("package.json"), |
| 912 | + r#"{"devEngines":{"runtime":{"name":"node","version":"not-a-version"}}}"#, |
| 913 | + ) |
| 914 | + .await |
| 915 | + .unwrap(); |
| 916 | + |
| 917 | + assert!(resolve_project_version_source(&child, false).await.unwrap().is_none()); |
| 918 | + } |
| 919 | + |
| 920 | + #[tokio::test] |
| 921 | + async fn test_project_source_returns_none_for_invalid_project_source() { |
| 922 | + let temp_dir = TempDir::new().unwrap(); |
| 923 | + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); |
| 924 | + tokio::fs::write(temp_path.join(".node-version"), "not-a-version").await.unwrap(); |
| 925 | + |
| 926 | + assert!(resolve_project_version_source(&temp_path, false).await.unwrap().is_none()); |
| 927 | + } |
| 928 | + |
782 | 929 | #[tokio::test] |
783 | 930 | async fn test_resolve_version_latest_alias_in_node_version() { |
784 | 931 | let temp_dir = TempDir::new().unwrap(); |
|
0 commit comments