diff --git a/Cargo.lock b/Cargo.lock index 986a0aa5e3..832c081807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,6 +917,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const_format" version = "0.2.34" @@ -2450,6 +2463,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console 0.16.2", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "indoc" version = "2.0.6" @@ -2500,7 +2526,7 @@ version = "1.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5" dependencies = [ - "console", + "console 0.15.11", "once_cell", "similar", "tempfile", @@ -4339,6 +4365,12 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + [[package]] name = "postcard" version = "1.1.3" @@ -6760,6 +6792,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7052,6 +7090,7 @@ dependencies = [ "flate2", "futures-util", "hex", + "indicatif", "node-semver", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 15acec7d01..228c2d2f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ heck = "0.5.0" hex = "0.4.3" httpmock = "0.7" ignore = "0.4" +indicatif = "0.18" indexmap = "2.9.0" indoc = "2.0.5" infer = "0.19.0" diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml index 1177b3064b..b3524d750e 100644 --- a/crates/vite_js_runtime/Cargo.toml +++ b/crates/vite_js_runtime/Cargo.toml @@ -12,6 +12,7 @@ async-trait = { workspace = true } backon = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } +indicatif = { workspace = true } hex = { workspace = true } node-semver = { workspace = true } serde = { workspace = true } diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs index cc26ab649f..217824bb25 100644 --- a/crates/vite_js_runtime/src/dev_engines.rs +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -1,13 +1,9 @@ -//! Package.json devEngines.runtime parsing and updating. +//! Package.json devEngines.runtime and engines.node parsing. //! -//! This module provides structs for parsing the `devEngines.runtime` field from package.json, -//! which can be either a single runtime object or an array of runtime objects. -//! It also provides functionality to update the runtime version in package.json. +//! This module provides structs for parsing the `devEngines.runtime` and `engines.node` +//! fields from package.json. It also handles `.node-version` file reading and writing. -use std::io::Write; - -use serde::{Deserialize, Serialize}; -use serde_json::ser::{Formatter, Serializer}; +use serde::Deserialize; use vite_path::AbsolutePath; use vite_str::Str; @@ -60,266 +56,80 @@ pub struct DevEngines { pub runtime: Option, } -/// Partial package.json structure for reading devEngines. +/// The engines section of package.json. +#[derive(Deserialize, Default, Debug)] +pub struct Engines { + /// Node.js version requirement (e.g., ">=20.0.0") + #[serde(default)] + pub node: Option, +} + +/// Partial package.json structure for reading devEngines and engines. #[derive(Deserialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub(crate) struct PackageJson { /// The devEngines configuration #[serde(default)] pub dev_engines: Option, + /// The engines configuration + #[serde(default)] + pub engines: Option, } -/// Detect indentation from JSON content (spaces or tabs, and count). -/// Returns (indent_char, indent_size) where indent_char is ' ' or '\t'. -fn detect_indentation(content: &str) -> (char, usize) { - for line in content.lines().skip(1) { - // Skip first line (usually just '{') - let trimmed = line.trim_start(); - if !trimmed.is_empty() && !trimmed.starts_with('}') && !trimmed.starts_with(']') { - let indent_chars: String = line.chars().take_while(|c| c.is_whitespace()).collect(); - if !indent_chars.is_empty() { - let first_char = indent_chars.chars().next().unwrap(); - return (first_char, indent_chars.len()); - } - } - } - (' ', 2) // Default: 2 spaces -} - -/// Custom JSON formatter that preserves the original indentation style. -struct CustomIndentFormatter { - indent: Vec, - current_indent: usize, -} - -impl CustomIndentFormatter { - fn new(indent_char: char, indent_size: usize) -> Self { - let indent = std::iter::repeat(indent_char as u8).take(indent_size).collect(); - Self { indent, current_indent: 0 } - } -} - -impl Formatter for CustomIndentFormatter { - fn begin_array(&mut self, writer: &mut W) -> std::io::Result<()> { - self.current_indent += 1; - writer.write_all(b"[") - } - - fn end_array(&mut self, writer: &mut W) -> std::io::Result<()> { - self.current_indent -= 1; - writer.write_all(b"\n")?; - write_indent(writer, &self.indent, self.current_indent)?; - writer.write_all(b"]") - } - - fn begin_array_value( - &mut self, - writer: &mut W, - first: bool, - ) -> std::io::Result<()> { - if first { - writer.write_all(b"\n")?; - } else { - writer.write_all(b",\n")?; - } - write_indent(writer, &self.indent, self.current_indent) - } - - fn end_array_value(&mut self, _writer: &mut W) -> std::io::Result<()> { - Ok(()) - } - - fn begin_object(&mut self, writer: &mut W) -> std::io::Result<()> { - self.current_indent += 1; - writer.write_all(b"{") - } - - fn end_object(&mut self, writer: &mut W) -> std::io::Result<()> { - self.current_indent -= 1; - writer.write_all(b"\n")?; - write_indent(writer, &self.indent, self.current_indent)?; - writer.write_all(b"}") - } - - fn begin_object_key( - &mut self, - writer: &mut W, - first: bool, - ) -> std::io::Result<()> { - if first { - writer.write_all(b"\n")?; - } else { - writer.write_all(b",\n")?; - } - write_indent(writer, &self.indent, self.current_indent) - } - - fn end_object_key(&mut self, _writer: &mut W) -> std::io::Result<()> { - Ok(()) - } - - fn begin_object_value(&mut self, writer: &mut W) -> std::io::Result<()> { - writer.write_all(b": ") - } - - fn end_object_value(&mut self, _writer: &mut W) -> std::io::Result<()> { - Ok(()) - } -} - -fn write_indent( - writer: &mut W, - indent: &[u8], - count: usize, -) -> std::io::Result<()> { - for _ in 0..count { - writer.write_all(indent)?; - } - Ok(()) -} - -/// Serialize JSON value with custom indentation. -fn serialize_with_indent( - value: &serde_json::Value, - indent_char: char, - indent_size: usize, -) -> String { - let mut buf = Vec::new(); - let formatter = CustomIndentFormatter::new(indent_char, indent_size); - let mut serializer = Serializer::with_formatter(&mut buf, formatter); - value.serialize(&mut serializer).unwrap(); - String::from_utf8(buf).unwrap() +/// Parse the content of a `.node-version` file. +/// +/// # Supported Formats +/// +/// - Three-part version: `20.5.0` +/// - With `v` prefix: `v20.5.0` +/// - Two-part version: `20.5` (treated as `^20.5.0` for resolution) +/// - Single-part version: `20` (treated as `^20.0.0` for resolution) +/// +/// # Returns +/// +/// The version string with any leading `v` prefix stripped. +/// Returns `None` if the content is empty or contains only whitespace. +#[must_use] +pub fn parse_node_version_content(content: &str) -> Option { + let version = content.lines().next()?.trim(); + if version.is_empty() { + return None; + } + // Strip optional 'v' prefix + let version = version.strip_prefix('v').unwrap_or(version); + Some(version.into()) } -/// Update or create the devEngines.runtime field with the given runtime name and version. -fn update_or_create_runtime( - package_json: &mut serde_json::Value, - runtime_name: &str, - version: &str, -) { - let obj = package_json.as_object_mut().unwrap(); - - // Ensure devEngines exists - if !obj.contains_key("devEngines") { - obj.insert("devEngines".to_string(), serde_json::json!({})); - } - - let dev_engines = obj.get_mut("devEngines").unwrap().as_object_mut().unwrap(); - - // Check if runtime exists - if let Some(runtime) = dev_engines.get_mut("runtime") { - match runtime { - serde_json::Value::Array(arr) => { - // Find and update the matching runtime entry - for entry in arr.iter_mut() { - if let Some(name) = entry.get("name").and_then(|n| n.as_str()) { - if name == runtime_name { - entry.as_object_mut().unwrap().insert( - "version".to_string(), - serde_json::Value::String(version.to_string()), - ); - return; - } - } - } - // If not found in array, add a new entry - arr.push(serde_json::json!({ - "name": runtime_name, - "version": version - })); - } - serde_json::Value::Object(obj) => { - // Single object format - check if name matches - let name_matches = - obj.get("name").and_then(|n| n.as_str()).is_some_and(|n| n == runtime_name); - let name_missing = !obj.contains_key("name"); - - if name_matches || name_missing { - // Name matches or no name set - update in place - obj.insert( - "version".to_string(), - serde_json::Value::String(version.to_string()), - ); - if name_missing { - obj.insert( - "name".to_string(), - serde_json::Value::String(runtime_name.to_string()), - ); - } - } else { - // Different runtime - convert to array format - let existing = runtime.clone(); - *runtime = serde_json::json!([ - existing, - { - "name": runtime_name, - "version": version - } - ]); - } - } - _ => { - // Invalid format, replace with proper object - *runtime = serde_json::json!({ - "name": runtime_name, - "version": version - }); - } - } - } else { - // No runtime field, create it as a single object - dev_engines.insert( - "runtime".to_string(), - serde_json::json!({ - "name": runtime_name, - "version": version - }), - ); - } +/// Read and parse a `.node-version` file from the project root. +/// +/// # Arguments +/// * `project_path` - The path to the project directory +/// +/// # Returns +/// The version string if the file exists and contains a valid version. +pub async fn read_node_version_file(project_path: &AbsolutePath) -> Option { + let path = project_path.join(".node-version"); + let content = tokio::fs::read_to_string(&path).await.ok()?; + parse_node_version_content(&content) } -/// Update devEngines.runtime in package.json with the resolved version. +/// Write a version to the `.node-version` file. /// -/// This function reads the package.json, detects the original indentation style, -/// updates or creates the devEngines.runtime field, and writes back with preserved formatting. +/// Creates the file if it doesn't exist, overwrites if it does. +/// Uses three-part version without `v` prefix and Unix line ending. /// /// # Arguments -/// * `package_json_path` - Path to the package.json file -/// * `runtime_name` - The runtime name (e.g., "node") -/// * `version` - The resolved version string (e.g., "20.18.0") +/// * `project_path` - The path to the project directory +/// * `version` - The version string (e.g., "22.13.1") /// /// # Errors -/// Returns an error if the file cannot be read, parsed, or written. -pub async fn update_runtime_version( - package_json_path: &AbsolutePath, - runtime_name: &str, +/// Returns an error if the file cannot be written. +pub async fn write_node_version_file( + project_path: &AbsolutePath, version: &str, ) -> Result<(), Error> { - // 1. Read original content - let content = tokio::fs::read_to_string(package_json_path).await?; - - // 2. Detect original indentation - let (indent_char, indent_size) = detect_indentation(&content); - - // 3. Parse JSON (preserve_order feature maintains key order) - let mut package_json: serde_json::Value = serde_json::from_str(&content)?; - - // 4. Update devEngines.runtime with version - update_or_create_runtime(&mut package_json, runtime_name, version); - - // 5. Serialize with original indentation - let mut new_content = serialize_with_indent(&package_json, indent_char, indent_size); - - // 6. Preserve trailing newline if original had one - if content.ends_with('\n') && !new_content.ends_with('\n') { - new_content.push('\n'); - } - - // 7. Write back (only if changed) - if new_content != content { - tokio::fs::write(package_json_path, new_content).await?; - } - + let path = project_path.join(".node-version"); + tokio::fs::write(&path, format!("{version}\n")).await?; Ok(()) } @@ -423,315 +233,102 @@ mod tests { } #[test] - fn test_detect_indentation_2_spaces() { - let content = r#"{ - "name": "test" -}"#; - let (indent_char, indent_size) = detect_indentation(content); - assert_eq!(indent_char, ' '); - assert_eq!(indent_size, 2); + fn test_parse_node_version_content_three_part() { + assert_eq!(parse_node_version_content("20.5.0\n"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("20.5.0"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("22.13.1\n"), Some("22.13.1".into())); } #[test] - fn test_detect_indentation_4_spaces() { - let content = r#"{ - "name": "test" -}"#; - let (indent_char, indent_size) = detect_indentation(content); - assert_eq!(indent_char, ' '); - assert_eq!(indent_size, 4); + fn test_parse_node_version_content_with_v_prefix() { + assert_eq!(parse_node_version_content("v20.5.0\n"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("v20.5.0"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("v22.13.1\n"), Some("22.13.1".into())); } #[test] - fn test_detect_indentation_tabs() { - let content = "{\n\t\"name\": \"test\"\n}"; - let (indent_char, indent_size) = detect_indentation(content); - assert_eq!(indent_char, '\t'); - assert_eq!(indent_size, 1); + fn test_parse_node_version_content_two_part() { + assert_eq!(parse_node_version_content("20.5\n"), Some("20.5".into())); + assert_eq!(parse_node_version_content("v20.5\n"), Some("20.5".into())); } #[test] - fn test_detect_indentation_default() { - let content = r#"{"name": "test"}"#; - let (indent_char, indent_size) = detect_indentation(content); - // Default is 2 spaces - assert_eq!(indent_char, ' '); - assert_eq!(indent_size, 2); + fn test_parse_node_version_content_single_part() { + assert_eq!(parse_node_version_content("20\n"), Some("20".into())); + assert_eq!(parse_node_version_content("v20\n"), Some("20".into())); } #[test] - fn test_update_or_create_runtime_no_dev_engines() { - let mut json: serde_json::Value = serde_json::json!({ - "name": "test-project" - }); - - update_or_create_runtime(&mut json, "node", "20.18.0"); - - assert_eq!(json["devEngines"]["runtime"]["name"].as_str().unwrap(), "node"); - assert_eq!(json["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + fn test_parse_node_version_content_with_whitespace() { + assert_eq!(parse_node_version_content(" 20.5.0 \n"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("\t20.5.0\t\n"), Some("20.5.0".into())); } #[test] - fn test_update_or_create_runtime_empty_dev_engines() { - let mut json: serde_json::Value = serde_json::json!({ - "name": "test-project", - "devEngines": {} - }); - - update_or_create_runtime(&mut json, "node", "20.18.0"); - - assert_eq!(json["devEngines"]["runtime"]["name"].as_str().unwrap(), "node"); - assert_eq!(json["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); - } - - #[test] - fn test_update_or_create_runtime_single_object_without_version() { - let mut json: serde_json::Value = serde_json::json!({ - "name": "test-project", - "devEngines": { - "runtime": { - "name": "node" - } - } - }); - - update_or_create_runtime(&mut json, "node", "20.18.0"); - - assert_eq!(json["devEngines"]["runtime"]["name"].as_str().unwrap(), "node"); - assert_eq!(json["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); - } - - #[test] - fn test_update_or_create_runtime_array_format() { - let mut json: serde_json::Value = serde_json::json!({ - "name": "test-project", - "devEngines": { - "runtime": [ - {"name": "deno", "version": "^2.0.0"}, - {"name": "node"} - ] - } - }); - - update_or_create_runtime(&mut json, "node", "20.18.0"); - - let runtimes = json["devEngines"]["runtime"].as_array().unwrap(); - assert_eq!(runtimes.len(), 2); - - // Node should be updated - let node = &runtimes[1]; - assert_eq!(node["name"].as_str().unwrap(), "node"); - assert_eq!(node["version"].as_str().unwrap(), "20.18.0"); - - // Deno should be unchanged - let deno = &runtimes[0]; - assert_eq!(deno["name"].as_str().unwrap(), "deno"); - assert_eq!(deno["version"].as_str().unwrap(), "^2.0.0"); - } - - #[test] - fn test_update_or_create_runtime_different_runtime_converts_to_array() { - // When updating with a different runtime name, should convert to array format - // to preserve both runtimes instead of corrupting the existing one - let mut json: serde_json::Value = serde_json::json!({ - "name": "test-project", - "devEngines": { - "runtime": { - "name": "deno", - "version": "^2.0.0" - } - } - }); - - update_or_create_runtime(&mut json, "node", "20.18.0"); - - // Should be converted to array format - let runtimes = json["devEngines"]["runtime"].as_array().unwrap(); - assert_eq!(runtimes.len(), 2); - - // Deno should be preserved at index 0 - let deno = &runtimes[0]; - assert_eq!(deno["name"].as_str().unwrap(), "deno"); - assert_eq!(deno["version"].as_str().unwrap(), "^2.0.0"); - - // Node should be added at index 1 - let node = &runtimes[1]; - assert_eq!(node["name"].as_str().unwrap(), "node"); - assert_eq!(node["version"].as_str().unwrap(), "20.18.0"); - } - - #[test] - fn test_serialize_with_indent_2_spaces() { - let json: serde_json::Value = serde_json::json!({ - "name": "test" - }); - - let output = serialize_with_indent(&json, ' ', 2); - let expected = r#"{ - "name": "test" -}"#; - assert_eq!(output, expected); - } - - #[test] - fn test_serialize_with_indent_4_spaces() { - let json: serde_json::Value = serde_json::json!({ - "name": "test" - }); - - let output = serialize_with_indent(&json, ' ', 4); - let expected = r#"{ - "name": "test" -}"#; - assert_eq!(output, expected); - } - - #[test] - fn test_serialize_with_tabs() { - let json: serde_json::Value = serde_json::json!({ - "name": "test" - }); - - let output = serialize_with_indent(&json, '\t', 1); - let expected = "{\n\t\"name\": \"test\"\n}"; - assert_eq!(output, expected); + fn test_parse_node_version_content_empty() { + assert!(parse_node_version_content("").is_none()); + assert!(parse_node_version_content("\n").is_none()); + assert!(parse_node_version_content(" \n").is_none()); } #[tokio::test] - async fn test_update_runtime_version_creates_dev_engines() { + async fn test_read_node_version_file() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_path.join("package.json"); - // Create package.json without devEngines - let original = r#"{ - "name": "test-project" -} -"#; - tokio::fs::write(&package_json_path, original).await.unwrap(); - - update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); - - let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); - let expected = r#"{ - "name": "test-project", - "devEngines": { - "runtime": { - "name": "node", - "version": "20.18.0" - } - } -} -"#; - assert_eq!(content, expected); - } - - #[tokio::test] - async fn test_update_runtime_version_preserves_4_space_indent() { - use tempfile::TempDir; - use vite_path::AbsolutePathBuf; + // File doesn't exist + assert!(read_node_version_file(&temp_path).await.is_none()); - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_path.join("package.json"); - - // Create package.json with 4-space indentation - let original = r#"{ - "name": "test-project", - "devEngines": { - "runtime": { - "name": "node" - } - } -} -"#; - tokio::fs::write(&package_json_path, original).await.unwrap(); - - update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); - - let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); - let expected = r#"{ - "name": "test-project", - "devEngines": { - "runtime": { - "name": "node", - "version": "20.18.0" - } - } -} -"#; - assert_eq!(content, expected); + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "22.13.1\n").await.unwrap(); + assert_eq!(read_node_version_file(&temp_path).await, Some("22.13.1".into())); } #[tokio::test] - async fn test_update_runtime_version_preserves_tab_indent() { + async fn test_write_node_version_file() { use tempfile::TempDir; use vite_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_path.join("package.json"); - // Create package.json with tab indentation - let original = "{\n\t\"name\": \"test-project\"\n}\n"; - tokio::fs::write(&package_json_path, original).await.unwrap(); + write_node_version_file(&temp_path, "22.13.1").await.unwrap(); - update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); + let content = tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(content, "22.13.1\n"); - let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); - let expected = "{\n\t\"name\": \"test-project\",\n\t\"devEngines\": {\n\t\t\"runtime\": {\n\t\t\t\"name\": \"node\",\n\t\t\t\"version\": \"20.18.0\"\n\t\t}\n\t}\n}\n"; - assert_eq!(content, expected); + // Verify it can be read back + assert_eq!(read_node_version_file(&temp_path).await, Some("22.13.1".into())); } - #[tokio::test] - async fn test_update_runtime_version_updates_array_format() { - use tempfile::TempDir; - use vite_path::AbsolutePathBuf; + #[test] + fn test_parse_engines_node() { + let json = r#"{"engines":{"node":">=20.0.0"}}"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + } - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_path.join("package.json"); - - // Create package.json with array runtime format - let original = r#"{ - "name": "test-project", - "devEngines": { - "runtime": [ - { - "name": "deno", - "version": "^2.0.0" - }, - { - "name": "node" - } - ] - } -} -"#; - tokio::fs::write(&package_json_path, original).await.unwrap(); - - update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); - - let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); - let expected = r#"{ - "name": "test-project", - "devEngines": { - "runtime": [ - { - "name": "deno", - "version": "^2.0.0" - }, - { - "name": "node", - "version": "20.18.0" - } - ] - } -} -"#; - assert_eq!(content, expected); + #[test] + fn test_parse_engines_node_empty() { + let json = r#"{"engines":{}}"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert!(pkg.engines.unwrap().node.is_none()); + } + + #[test] + fn test_parse_both_engines_and_dev_engines() { + let json = r#"{ + "engines": {"node": ">=20.0.0"}, + "devEngines": {"runtime": {"name": "node", "version": "^24.4.0"}} + }"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.version, "^24.4.0"); } } diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 1b16d12610..42a4e9383d 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -3,10 +3,11 @@ //! This module provides platform-agnostic utilities for downloading, //! verifying, and extracting runtime archives. -use std::{fs::File, time::Duration}; +use std::{fs::File, io::IsTerminal, time::Duration}; use backon::{ExponentialBuilder, Retryable}; use futures_util::StreamExt; +use indicatif::{ProgressBar, ProgressStyle}; use sha2::{Digest, Sha256}; use tokio::{fs, io::AsyncWriteExt}; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -27,8 +28,15 @@ pub struct CachedFetchResponse { pub not_modified: bool, } -/// Download a file with retry logic -pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { +/// Download a file with retry logic and progress bar +/// +/// The `message` parameter is displayed to the user to indicate what is being downloaded +/// (e.g., "Downloading Node.js v22.13.1"). +pub async fn download_file( + url: &str, + target_path: &AbsolutePath, + message: &str, +) -> Result<(), Error> { tracing::debug!("Downloading {url} to {target_path:?}"); let response = (|| async { reqwest::get(url).await?.error_for_status() }) @@ -41,16 +49,63 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), .await .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; - // Stream to file + // Get Content-Length for progress bar + let total_size = response.content_length(); + + // Create progress bar (only in TTY and not in CI) + let is_ci = std::env::var("CI").is_ok(); + let progress = if std::io::stderr().is_terminal() && !is_ci { + let pb = match total_size { + Some(size) => { + let pb = ProgressBar::new(size); + pb.set_style( + ProgressStyle::default_bar() + .template( + "{msg}\n{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \ + {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", + ) + .expect("valid progress bar template") + .progress_chars("#>-"), + ); + pb + } + None => { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template( + "{msg}\n{spinner:.green} [{elapsed_precise}] {bytes} ({bytes_per_sec})", + ) + .expect("valid spinner template"), + ); + pb.enable_steady_tick(Duration::from_millis(100)); + pb + } + }; + pb.set_message(message.to_string()); + Some(pb) + } else { + None + }; + + // Stream to file with progress updates let mut file = fs::File::create(target_path).await?; let mut stream = response.bytes_stream(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result?; + if let Some(ref pb) = progress { + pb.inc(chunk.len() as u64); + } file.write_all(&chunk).await?; } file.flush().await?; + + if let Some(pb) = progress { + pb.finish_and_clear(); + } + tracing::debug!("Download completed: {target_path:?}"); Ok(()) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 3184954ff3..21d65e4ce6 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -93,15 +93,17 @@ impl NodeProvider { /// Find a locally cached version that satisfies the version requirement. /// /// This checks the local cache directory for installed Node.js versions - /// and returns the highest version that satisfies the semver range. + /// and returns a version that satisfies the semver range. Prefers LTS + /// versions over non-LTS versions. /// /// # Arguments /// * `version_req` - A semver range requirement (e.g., "^20.18.0") - /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite/js_runtime`) + /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite-plus/js_runtime`) /// /// # Returns - /// The highest cached version that satisfies the requirement, or `None` if - /// no cached version matches. + /// The highest LTS cached version that satisfies the requirement, or the + /// highest non-LTS version if no LTS version matches, or `None` if no + /// cached version matches. /// /// # Errors /// Returns an error if the version requirement is invalid. @@ -136,7 +138,29 @@ impl NodeProvider { } } - // Return highest matching version using semver comparison + if matching_versions.is_empty() { + return Ok(None); + } + + // Fetch version index to check LTS status + let version_index = self.fetch_version_index().await?; + + // Build a set of LTS versions for fast lookup + let lts_versions: std::collections::HashSet = version_index + .iter() + .filter(|e| e.is_lts()) + .map(|e| e.version.strip_prefix('v').unwrap_or(&e.version).to_string()) + .collect(); + + // Prefer LTS: find highest LTS cached version first + let lts_max = + matching_versions.iter().filter(|v| lts_versions.contains(&v.to_string())).max(); + + if let Some(version) = lts_max { + return Ok(Some(version.to_string().into())); + } + + // Fallback to highest non-LTS Ok(matching_versions.into_iter().max().map(|v| v.to_string().into())) } @@ -151,43 +175,52 @@ impl NodeProvider { /// Fetch the version index from nodejs.org/dist/index.json with HTTP caching. /// /// Uses ETag-based conditional requests to minimize bandwidth when cache expires. + /// If a network error occurs and a local cache exists (even if expired), returns + /// the cached version with a warning log instead of failing. /// /// # Errors /// - /// Returns an error if the download fails or the JSON is invalid. + /// Returns an error only if the download fails and no local cache exists. pub async fn fetch_version_index(&self) -> Result, Error> { let cache_dir = crate::cache::get_cache_dir()?; let cache_path = cache_dir.join("node/index_cache.json"); // Try to load from cache - if let Some(cache) = load_cache(&cache_path).await { - let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); - - // If cache is still fresh, use it - if now < cache.expires_at { - tracing::debug!( - "Using cached version index (expires in {}s)", - cache.expires_at - now - ); - return Ok(cache.versions); - } + let Some(cache) = load_cache(&cache_path).await else { + // No cache - must fetch + return self.fetch_and_cache(&cache_path).await; + }; - // Cache expired - try conditional request with ETag if available - if let Some(etag) = &cache.etag { - tracing::debug!("Cache expired, trying conditional request with ETag"); - match self.fetch_with_etag(etag, &cache, &cache_path).await { - Ok(versions) => return Ok(versions), - Err(e) => { - tracing::debug!("Conditional request failed: {e}, doing full fetch"); - } + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + // If cache is still fresh, use it + if now < cache.expires_at { + tracing::debug!("Using cached version index (expires in {}s)", cache.expires_at - now); + return Ok(cache.versions); + } + + // Cache expired - try conditional request with ETag if available + if let Some(ref etag) = cache.etag { + tracing::debug!("Cache expired, trying conditional request with ETag"); + match self.fetch_with_etag(etag, &cache, &cache_path).await { + Ok(versions) => return Ok(versions), + Err(e) => { + // Network error with ETag request - return cached version + tracing::warn!("Conditional request failed: {e}, using expired cache"); + return Ok(cache.versions); } - } else { - tracing::debug!("Cache expired, no ETag available for conditional request"); } } - // Full fetch - self.fetch_and_cache(&cache_path).await + // No ETag - try full fetch, fallback to cache + tracing::debug!("Cache expired, no ETag available for conditional request"); + match self.fetch_and_cache(&cache_path).await { + Ok(versions) => Ok(versions), + Err(e) => { + tracing::warn!("Failed to fetch version index: {e}, using expired cache"); + Ok(cache.versions) + } + } } /// Try conditional fetch with ETag, returns cached versions if 304 @@ -300,7 +333,11 @@ fn find_latest_lts_version(versions: &[NodeVersionEntry]) -> Result }) } -/// Resolve a version requirement to the highest matching version from a list. +/// Resolve a version requirement to a matching version from a list. +/// +/// Prefers LTS versions over non-LTS versions. Returns the highest LTS version +/// that satisfies the range, or falls back to the highest non-LTS version if +/// no LTS version matches. /// /// # Errors /// @@ -311,17 +348,33 @@ fn resolve_version_from_list( ) -> Result { let range = Range::parse(version_req)?; - let max_matching = versions + // Collect all matching versions with their LTS status + let matching_versions: Vec<(Version, &str, bool)> = versions .iter() .filter_map(|entry| { let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); - Version::parse(version_str).ok().map(|v| (v, version_str)) + Version::parse(version_str) + .ok() + .filter(|v| range.satisfies(v)) + .map(|v| (v, version_str, entry.is_lts())) }) - .filter(|(version, _)| range.satisfies(version)) - .max_by(|(a, _), (b, _)| a.cmp(b)); + .collect(); + + // Prefer LTS versions: find highest LTS first + let lts_max = matching_versions + .iter() + .filter(|(_, _, is_lts)| *is_lts) + .max_by(|(a, _, _), (b, _, _)| a.cmp(b)); + + if let Some((_, version_str, _)) = lts_max { + return Ok((*version_str).into()); + } - max_matching - .map(|(_, version_str)| version_str.into()) + // Fallback to highest non-LTS version + matching_versions + .into_iter() + .max_by(|(a, _, _), (b, _, _)| a.cmp(b)) + .map(|(_, version_str, _)| version_str.into()) .ok_or_else(|| Error::NoMatchingVersion { version_req: version_req.into() }) } @@ -892,4 +945,65 @@ fedcba987654 node-v22.13.1-win-x64.zip"; let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap(); assert!(result.is_none()); } + + #[test] + fn test_resolve_version_from_list_prefers_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v24.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v22.15.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // Should prefer highest LTS (v24.5.0) over non-LTS (v25.5.0) + let result = resolve_version_from_list(">=20.0.0", &versions).unwrap(); + assert_eq!(result, "24.5.0"); + } + + #[test] + fn test_resolve_version_from_list_falls_back_to_non_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v25.4.0".into(), lts: LtsInfo::NotLts }, + ]; + + // No LTS matches, should return highest non-LTS + let result = resolve_version_from_list(">24.9999.0", &versions).unwrap(); + assert_eq!(result, "25.5.0"); + } + + #[test] + fn test_resolve_version_from_list_complex_range_prefers_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v24.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v22.15.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // ^20.19.0 || >=22.12.0 should prefer v24.5.0 (highest LTS) over v25.5.0 + let result = resolve_version_from_list("^20.19.0 || >=22.12.0", &versions).unwrap(); + assert_eq!(result, "24.5.0"); + } + + #[test] + fn test_resolve_version_from_list_only_matches_in_range_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v24.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // ^20.18.0 should return 20.19.0 (the only LTS in range) + let result = resolve_version_from_list("^20.18.0", &versions).unwrap(); + assert_eq!(result, "20.19.0"); + } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index c3152a1861..7e542b1d8c 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -1,10 +1,11 @@ +use node_semver::{Range, Version}; use tempfile::TempDir; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; use crate::{ Error, Platform, - dev_engines::{PackageJson, update_runtime_version}, + dev_engines::{PackageJson, read_node_version_file, write_node_version_file}, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider}, providers::NodeProvider, @@ -110,11 +111,10 @@ pub async fn download_runtime_with_provider( let cache_dir = crate::cache::get_cache_dir()?; // Get paths from provider - let platform_str = provider.platform_string(platform); let binary_relative_path = provider.binary_relative_path(platform); let bin_dir_relative_path = provider.bin_dir_relative_path(platform); - // Cache path: $CACHE_DIR/vite/js_runtime/{runtime}/{version}/ + // Cache path: $CACHE_DIR/vite-plus/js_runtime/{runtime}/{version}/ let install_dir = cache_dir.join(vite_str::format!("{}/{version}", provider.name())); // Check if already cached @@ -138,7 +138,8 @@ pub async fn download_runtime_with_provider( tokio::fs::remove_dir_all(&install_dir).await?; } - tracing::info!("Downloading {} {version} for {platform_str}...", provider.name()); + let download_message = format!("Downloading {} v{version}...", provider.name()); + tracing::info!("{download_message}"); // Get download info from provider let download_info = provider.get_download_info(version, platform); @@ -158,7 +159,7 @@ pub async fn download_runtime_with_provider( provider.parse_shasums(&shasums_content, &download_info.archive_filename)?; // Download archive - download_file(&download_info.archive_url, &archive_path).await?; + download_file(&download_info.archive_url, &archive_path, &download_message).await?; // Verify hash verify_file_hash(&archive_path, &expected_hash, &download_info.archive_filename) @@ -166,7 +167,7 @@ pub async fn download_runtime_with_provider( } HashVerification::None => { // Download archive without verification - download_file(&download_info.archive_url, &archive_path).await?; + download_file(&download_info.archive_url, &archive_path, &download_message).await?; } } @@ -188,95 +189,242 @@ pub async fn download_runtime_with_provider( }) } -/// Download runtime based on project's devEngines.runtime configuration. +/// Represents the source from which a Node.js version was read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionSource { + /// Version from `.node-version` file (highest priority) + NodeVersionFile, + /// Version from `engines.node` in package.json + EnginesNode, + /// Version from `devEngines.runtime` in package.json (lowest priority) + DevEnginesRuntime, + /// No version source specified, will use latest installed or LTS + None, +} + +impl std::fmt::Display for VersionSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NodeVersionFile => write!(f, ".node-version"), + Self::EnginesNode => write!(f, "engines.node"), + Self::DevEnginesRuntime => write!(f, "devEngines.runtime"), + Self::None => write!(f, "none"), + } + } +} + +/// Download runtime based on project's version configuration. +/// +/// Reads Node.js version from multiple sources with the following priority: +/// 1. `.node-version` file (highest) +/// 2. `engines.node` in package.json +/// 3. `devEngines.runtime` in package.json (lowest) /// -/// Reads the `devEngines.runtime` field from the project's package.json and downloads -/// the appropriate runtime version. If no configuration is found, downloads the latest -/// Node.js version. +/// If no version source is found, uses the latest installed version from cache, +/// or falls back to the latest LTS version from the network. +/// +/// When the resolved version from the highest priority source does NOT satisfy +/// constraints from lower priority sources, a warning is emitted. /// /// # Arguments -/// * `project_path` - The path to the project directory containing package.json +/// * `project_path` - The path to the project directory /// /// # Returns /// A `JsRuntime` instance with the installation path /// /// # Errors -/// Returns an error if package.json cannot be read/parsed, version resolution fails, -/// or download/extraction fails. +/// Returns an error if version resolution fails or download/extraction fails. /// /// # Note -/// Currently only supports Node.js runtime. Other runtimes in the configuration -/// (e.g., "deno", "bun") are ignored. +/// Currently only supports Node.js runtime. pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result { let package_json_path = project_path.join("package.json"); - let dev_engines = read_dev_engines(&package_json_path).await?; + let pkg = read_package_json(&package_json_path).await?; let provider = NodeProvider::new(); let cache_dir = crate::cache::get_cache_dir()?; - // Find the "node" runtime configuration (supports both single object and array) - let node_runtime = dev_engines + // 1. Read all version sources (with validation) + let node_version_file = read_node_version_file(project_path) + .await + .and_then(|v| normalize_version(&v, ".node-version")); + + let engines_node = pkg .as_ref() - .and_then(|de| de.runtime.as_ref()) - .and_then(|rt| rt.find_by_name("node")); + .and_then(|p| p.engines.as_ref()) + .and_then(|e| e.node.clone()) + .and_then(|v| normalize_version(&v, "engines.node")); - // Track if we need to write back (only when no version specified) - let should_write_back = match &node_runtime { - Some(runtime) => runtime.version.is_empty(), // No version = write back - None => true, // No runtime config = write back + let dev_engines_runtime = pkg + .as_ref() + .and_then(|p| p.dev_engines.as_ref()) + .and_then(|de| de.runtime.as_ref()) + .and_then(|rt| rt.find_by_name("node")) + .map(|r| r.version.clone()) + .filter(|v| !v.is_empty()) + .and_then(|v| normalize_version(&v, "devEngines.runtime")); + + tracing::debug!( + "Version sources - .node-version: {:?}, engines.node: {:?}, devEngines.runtime: {:?}", + node_version_file, + engines_node, + dev_engines_runtime + ); + + // 2. Select version from highest priority source that exists + let (version_req, source) = if let Some(ref v) = node_version_file { + (v.clone(), VersionSource::NodeVersionFile) + } else if let Some(ref v) = engines_node { + (v.clone(), VersionSource::EnginesNode) + } else if let Some(ref v) = dev_engines_runtime { + (v.clone(), VersionSource::DevEnginesRuntime) + } else { + (Str::default(), VersionSource::None) }; - let version = match node_runtime { - Some(runtime) if !runtime.version.is_empty() => { - let version_str = runtime.version.as_str(); - - // Optimization 1: Exact version - use directly without network request - if NodeProvider::is_exact_version(version_str) { - // Strip 'v' prefix if present (e.g., "v20.18.0" -> "20.18.0") - // because download URLs already add the 'v' prefix - let normalized = version_str.strip_prefix('v').unwrap_or(version_str); - tracing::debug!("Using exact version: {normalized}"); - normalized.into() - } else { - // Optimization 2: Range - check local cache first - if let Some(cached) = provider.find_cached_version(version_str, &cache_dir).await? { - tracing::debug!("Found cached version {cached} satisfying {version_str}"); - cached - } else { - // No cached version satisfies range, resolve from network - tracing::debug!("Resolving version requirement from network: {version_str}"); - provider.resolve_version(version_str).await? - } - } - } - Some(_) => { - // Runtime configured but no version specified, use latest - tracing::debug!("Node runtime configured without version, using latest"); - provider.resolve_latest_version().await? - } - // No node runtime configured, use latest - None => { - tracing::debug!("No devEngines.runtime configuration found, using latest Node.js"); - provider.resolve_latest_version().await? - } - }; + tracing::debug!("Selected version source: {source}, version_req: {version_req:?}"); + + // 3. Resolve version (if range/partial → exact) + let (version, should_write_back) = + resolve_version_for_project(&version_req, source, &provider, &cache_dir).await?; + + // 4. Check compatibility with lower priority sources + check_version_compatibility(&version, source, &engines_node, &dev_engines_runtime); tracing::info!("Resolved Node.js version: {version}"); let runtime = download_runtime(JsRuntimeType::Node, &version).await?; - // Write resolved version back to package.json (only when no version was specified) + // 5. Write resolved version to .node-version (if resolution occurred) if should_write_back { - if let Err(e) = update_runtime_version(&package_json_path, "node", &version).await { - tracing::warn!("Failed to update package.json with resolved version: {e}"); + if let Err(e) = write_node_version_file(project_path, &version).await { + tracing::warn!("Failed to write .node-version: {e}"); + } else { + tracing::info!("Using Node {version} - saved version to .node-version"); } } Ok(runtime) } -/// Read devEngines configuration from package.json. -async fn read_dev_engines( +/// Resolve version requirement to an exact version. +/// +/// Returns (resolved_version, should_write_back). +async fn resolve_version_for_project( + version_req: &str, + _source: VersionSource, + provider: &NodeProvider, + cache_dir: &AbsolutePath, +) -> Result<(Str, bool), Error> { + if version_req.is_empty() { + // No source specified - fetch latest LTS from network + tracing::debug!("No version source specified, fetching latest LTS from network"); + let version = provider.resolve_latest_version().await?; + return Ok((version, true)); + } + + // Check if it's an exact version + if NodeProvider::is_exact_version(version_req) { + let normalized = version_req.strip_prefix('v').unwrap_or(version_req); + tracing::debug!("Using exact version: {normalized}"); + // Never write back exact versions - user explicitly specified the version + return Ok((normalized.into(), false)); + } + + // Check local cache first + if let Some(cached) = provider.find_cached_version(version_req, cache_dir).await? { + tracing::debug!("Found cached version {cached} satisfying {version_req}"); + // Don't write back - user specified a version requirement + return Ok((cached, false)); + } + + // Resolve from network + tracing::debug!("Resolving version requirement from network: {version_req}"); + let version = provider.resolve_version(version_req).await?; + + // Don't write back - user specified a version requirement + Ok((version, false)) +} + +/// Check if the resolved version is compatible with lower priority sources. +/// Emit warnings if incompatible. +fn check_version_compatibility( + resolved_version: &str, + source: VersionSource, + engines_node: &Option, + dev_engines_runtime: &Option, +) { + let parsed = match Version::parse(resolved_version) { + Ok(v) => v, + Err(_) => return, // Can't check compatibility without a valid version + }; + + // Check engines.node if it's a lower priority source + if source != VersionSource::EnginesNode { + if let Some(req) = engines_node { + check_constraint(&parsed, req, "engines.node", resolved_version, source); + } + } + + // Check devEngines.runtime if it's a lower priority source + if source != VersionSource::DevEnginesRuntime { + if let Some(req) = dev_engines_runtime { + check_constraint(&parsed, req, "devEngines.runtime", resolved_version, source); + } + } +} + +/// Check if a version satisfies a constraint and warn if not. +fn check_constraint( + version: &Version, + constraint: &str, + constraint_source: &str, + resolved_version: &str, + source: VersionSource, +) { + match Range::parse(constraint) { + Ok(range) => { + if !range.satisfies(version) { + println!( + "warning: Node.js version {resolved_version} (from {source}) does not satisfy \ + {constraint_source} constraint '{constraint}'" + ); + } + } + Err(e) => { + tracing::debug!("Failed to parse {constraint_source} constraint '{constraint}': {e}"); + } + } +} + +/// Normalize and validate a version string as semver (exact version or range). +/// Trims whitespace and returns the normalized version, or None with a warning if invalid. +fn normalize_version(version: &Str, source: &str) -> Option { + // Trim leading/trailing whitespace + let trimmed: Str = version.trim().into(); + + if trimmed.is_empty() { + return None; + } + + // Try parsing as exact version (strip 'v' prefix for exact version check) + let without_v = trimmed.strip_prefix('v').unwrap_or(&trimmed); + if Version::parse(without_v).is_ok() { + return Some(trimmed); + } + + // Try parsing as range + if Range::parse(&trimmed).is_ok() { + return Some(trimmed); + } + + // Invalid version + println!("warning: invalid version '{version}' in {source}, ignoring"); + None +} + +/// Read package.json contents. +async fn read_package_json( package_json_path: &AbsolutePathBuf, -) -> Result, Error> { +) -> Result, Error> { if !tokio::fs::try_exists(package_json_path).await.unwrap_or(false) { tracing::debug!("package.json not found at {:?}", package_json_path); return Ok(None); @@ -284,7 +432,7 @@ async fn read_dev_engines( let content = tokio::fs::read_to_string(package_json_path).await?; let pkg: PackageJson = serde_json::from_str(&content)?; - Ok(pkg.dev_engines) + Ok(Some(pkg)) } #[cfg(test)] @@ -365,20 +513,14 @@ mod tests { let parsed = node_semver::Version::parse(version).unwrap(); assert!(parsed.major >= 20); - // Should write resolved version back to package.json with exact formatting - let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let expected = format!( - r#"{{ - "name": "test-project", - "devEngines": {{ - "runtime": {{ - "name": "node", - "version": "{version}" - }} - }} -}}"# - ); - assert_eq!(content, expected); + // Should write resolved version to .node-version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, format!("{version}\n")); + + // package.json should remain unchanged + let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); } #[tokio::test] @@ -401,21 +543,14 @@ mod tests { let runtime = download_runtime_for_project(&temp_path).await.unwrap(); let version = runtime.version(); - // Should write resolved version back to package.json with exact formatting - let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let expected = format!( - r#"{{ - "name": "test-project", - "devEngines": {{ - "runtime": {{ - "name": "node", - "version": "{version}" - }} - }} -}} -"# - ); - assert_eq!(content, expected); + // Should write resolved version to .node-version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, format!("{version}\n")); + + // package.json should remain unchanged + let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); } #[tokio::test] @@ -436,14 +571,17 @@ mod tests { "#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + + // Should NOT write .node-version since a version was specified + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); - // Should NOT modify package.json (version range was specified) - let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - // Version should still be the range, not the resolved version - assert!(content.contains("\"version\": \"^20.18.0\"")); - // Content should be unchanged - assert_eq!(content, package_json); + // package.json should remain unchanged + let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); } #[tokio::test] @@ -628,4 +766,319 @@ mod tests { "Version output should contain {version}, got: {version_output}" ); } + + // ========================================== + // Multi-source version reading tests + // ========================================== + + #[tokio::test] + async fn test_node_version_file_takes_priority() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with exact version + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Create package.json with engines.node (should be ignored) + let package_json = r#"{"engines":{"node":">=22.0.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.version(), "20.18.0"); + + // Should NOT write back since .node-version had exact version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20.18.0\n"); + } + + #[tokio::test] + async fn test_engines_node_takes_priority_over_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with both engines.node and devEngines.runtime + let package_json = r#"{ + "engines": {"node": "^20.18.0"}, + "devEngines": {"runtime": {"name": "node", "version": "^22.0.0"}} +}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + // Should use engines.node (^20.18.0), which will resolve to a 20.x version + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[tokio::test] + async fn test_only_engines_node_source() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with only engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + + // Should NOT write .node-version since a version was specified + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + } + + #[tokio::test] + async fn test_node_version_file_partial_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with partial version (two parts) + tokio::fs::write(temp_path.join(".node-version"), "20.18\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Should resolve to a 20.18.x or higher version in 20.x line + assert_eq!(parsed.major, 20); + // Minor version should be at least 18 + assert!(parsed.minor >= 18, "Expected minor >= 18, got {}", parsed.minor); + + // Should NOT write back - .node-version already has a version specified + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20.18\n"); + } + + #[tokio::test] + async fn test_node_version_file_single_part_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with single-part version + tokio::fs::write(temp_path.join(".node-version"), "20\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Should resolve to a 20.x.x version + assert_eq!(parsed.major, 20); + + // Should NOT write back - .node-version already has a version specified + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20\n"); + } + + #[test] + fn test_version_source_display() { + 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::None.to_string(), "none"); + } + + // ========================================== + // Invalid version validation tests + // ========================================== + + #[tokio::test] + async fn test_invalid_node_version_file_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + + // Create package.json without any version + let package_json = r#"{"name": "test-project"}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since .node-version is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_invalid_engines_node_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid engines.node + let package_json = r#"{"engines":{"node":"invalid"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since engines.node is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_invalid_dev_engines_runtime_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid devEngines.runtime version + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"invalid"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since devEngines.runtime is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_invalid_node_version_file_falls_through_to_valid_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + + // Create package.json with valid engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should use engines.node since .node-version is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[tokio::test] + async fn test_invalid_engines_falls_through_to_valid_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid engines.node but valid devEngines.runtime + 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(); + + // Should use devEngines.runtime since engines.node is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[test] + fn test_normalize_version_exact() { + let version = Str::from("20.18.0"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_with_v_prefix() { + let version = Str::from("v20.18.0"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_range() { + let version = Str::from("^20.18.0"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_partial() { + // Partial versions like "20" or "20.18" should be valid as ranges + let version = Str::from("20"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + + let version = Str::from("20.18"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_invalid() { + let version = Str::from("invalid"); + assert_eq!(normalize_version(&version, "test"), None); + + let version = Str::from("not-a-version"); + assert_eq!(normalize_version(&version, "test"), None); + } + + #[test] + fn test_normalize_version_real_world_ranges() { + // Test various real-world version range formats + let valid_ranges = [ + ">=18", + ">=18 <21", + "^18.18.0", + "~20.11.1", + "18.x", + "20.*", + "18 || 20 || >=22", + ">=16 <=20", + ">=20.0.0-rc.0", + "*", + ]; + + for range in valid_ranges { + let version = Str::from(range); + assert_eq!( + normalize_version(&version, "test"), + Some(version.clone()), + "Expected '{range}' to be valid" + ); + } + } + + #[test] + fn test_normalize_version_with_negation() { + // node-semver crate supports negation syntax + let version = Str::from(">=18 !=19.0.0 <21"); + assert_eq!( + normalize_version(&version, "test"), + Some(version.clone()), + "Expected '>=18 !=19.0.0 <21' to be valid" + ); + } + + #[test] + fn test_normalize_version_with_whitespace() { + // Versions with leading/trailing whitespace are trimmed + let version = Str::from(" 20 "); + assert_eq!( + normalize_version(&version, "test"), + Some(Str::from("20")), + "Expected ' 20 ' to be trimmed to '20'" + ); + + let version = Str::from(" v20.2.0 "); + assert_eq!( + normalize_version(&version, "test"), + Some(Str::from("v20.2.0")), + "Expected ' v20.2.0 ' to be trimmed to 'v20.2.0'" + ); + } + + #[test] + fn test_normalize_version_empty_or_whitespace_only() { + let version = Str::from(""); + assert_eq!(normalize_version(&version, "test"), None); + + let version = Str::from(" "); + assert_eq!(normalize_version(&version, "test"), None); + } } diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 3a3169412e..f6a3470e80 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -20,7 +20,7 @@ The PackageManager implementation in `vite_install` successfully handles automat ## Non-Goals (Initial Version) -- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `devEngines.runtime`** +- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `.node-version`, `engines.node`, and `devEngines.runtime`** - Managing multiple runtime versions simultaneously - Providing a version manager CLI (like nvm/fnm) - Supporting custom/unofficial Node.js builds @@ -154,21 +154,14 @@ pub async fn download_runtime( version: &str, // Exact version (e.g., "22.13.1") ) -> Result; -/// Download runtime based on project's devEngines.runtime configuration -/// Reads package.json, resolves semver ranges, downloads the matching version -/// If no version was specified, writes the resolved version back to package.json +/// Download runtime based on project's version configuration +/// Reads from .node-version, engines.node, or devEngines.runtime (in priority order) +/// Resolves semver ranges, downloads the matching version +/// Writes resolved version to .node-version for future use pub async fn download_runtime_for_project( project_path: &AbsolutePath, ) -> Result; -/// Update devEngines.runtime in package.json with the resolved version -/// Preserves original formatting (indentation, key order, trailing newline) -pub async fn update_runtime_version( - package_json_path: &AbsolutePath, - runtime_name: &str, - version: &str, -) -> Result<(), Error>; - impl JsRuntime { /// Get the path to the runtime binary (e.g., node, bun) pub fn get_binary_path(&self) -> AbsolutePathBuf; @@ -207,7 +200,7 @@ println!("Node.js installed at: {}", runtime.get_binary_path()); println!("Version: {}", runtime.version()); // "22.13.1" ``` -**Project-based download (reads devEngines.runtime from package.json):** +**Project-based download (reads from .node-version, engines.node, or devEngines.runtime):** ```rust use vite_js_runtime::download_runtime_for_project; @@ -215,7 +208,8 @@ use vite_path::AbsolutePathBuf; let project_path = AbsolutePathBuf::new("/path/to/project".into()).unwrap(); let runtime = download_runtime_for_project(&project_path).await?; -// Version is resolved from devEngines.runtime or uses latest +// Version is resolved from .node-version > engines.node > devEngines.runtime +// Resolved version is saved to .node-version for future use ``` ## Cache Directory Structure @@ -269,11 +263,53 @@ Cache structure: | Windows | x64 | `win-x64` | | Windows | ARM64 | `win-arm64` | -## Project Configuration (devEngines.runtime) +## Version Source Priority + +The `download_runtime_for_project` function reads Node.js version from multiple sources with the following priority: + +| Priority | Source | File | Example | Used By | +| ----------- | -------------------- | --------------- | ------------------------------------- | ----------------------------- | +| 1 (highest) | `.node-version` | `.node-version` | `22.13.1` | fnm, nvm, Netlify, Cloudflare | +| 2 | `engines.node` | `package.json` | `">=20.0.0"` | Vercel, npm | +| 3 (lowest) | `devEngines.runtime` | `package.json` | `{"name":"node","version":"^24.4.0"}` | npm RFC | + +### `.node-version` File Format + +Reference: https://github.com/shadowspawn/node-version-usage + +**Supported Formats:** + +| Format | Example | Support Level | +| ------------------- | --------- | -------------------------------- | +| Three-part version | `20.5.0` | Universal | +| With `v` prefix | `v20.5.0` | Universal | +| Two-part version | `20.5` | Supported (treated as `^20.5.0`) | +| Single-part version | `20` | Supported (treated as `^20.0.0`) | -The `download_runtime_for_project` function reads the `devEngines.runtime` field from the project's package.json. This follows the [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md). +**Format Rules:** -### Single Runtime +1. Single line with Unix line ending (`\n`) +2. Trim whitespace from both ends +3. Optional `v` prefix - normalized by stripping +4. No comments - entire line is the version + +### `engines.node` Format + +Standard npm `engines` field in package.json: + +```json +{ + "engines": { + "node": ">=20.0.0" + } +} +``` + +### `devEngines.runtime` Format + +Following the [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md): + +**Single Runtime:** ```json { @@ -287,7 +323,7 @@ The `download_runtime_for_project` function reads the `devEngines.runtime` field } ``` -### Multiple Runtimes (Array) +**Multiple Runtimes (Array):** ```json { @@ -310,6 +346,24 @@ The `download_runtime_for_project` function reads the `devEngines.runtime` field **Note:** Currently only the `"node"` runtime is supported. Other runtimes are ignored. +### Version Validation + +Before using a version string from any source, it is normalized and validated: + +1. **Trim whitespace**: Leading and trailing whitespace is removed +2. **Validate as semver**: The version must be either: + - An exact version (e.g., `20.18.0`, `v20.18.0`) + - A valid semver range (e.g., `^20.0.0`, `>=18 <21`, `20.x`, `*`) +3. **Invalid versions are ignored**: If validation fails, a warning is printed and the source is skipped + +**Example warning:** + +``` +warning: invalid version 'latest' in .node-version, ignoring +``` + +This allows fallthrough to lower-priority sources when a higher-priority source contains an invalid version. + ### Version Resolution The version resolution is optimized to minimize network requests: @@ -319,11 +373,12 @@ The version resolution is optimized to minimize network requests: | Exact (`20.18.0`) | - | **No** | Use exact version directly | | Range (`^20.18.0`) | Match found | **No** | Use cached version | | Range (`^20.18.0`) | No match | **Yes** | Resolve from network | -| Empty/None | - | **Yes** | Get latest LTS version | +| Empty/None | Match found | **No** | Use latest cached version | +| Empty/None | No match | **Yes** | Get latest LTS version | **Exact versions** (e.g., `20.18.0`, `v20.18.0`) are detected using `node_semver::Version::parse()` and used directly without network validation. The `v` prefix is normalized (stripped) since download URLs already add it. -**Partial versions** like `20` or `20.18` are treated as ranges, not exact versions. +**Partial versions** like `20` or `20.18` are treated as ranges by the `node-semver` crate. **Semver ranges** (e.g., `^24.4.0`) trigger version resolution: @@ -334,59 +389,70 @@ The version resolution is optimized to minimize network requests: 5. Use `node-semver` crate for npm-compatible range matching 6. Return the highest version that satisfies the range +### Mismatch Detection + +When the resolved version from the highest priority source does NOT satisfy constraints from lower priority sources, a warning is emitted. + +| .node-version | engines.node | devEngines | Resolved | Warning? | +| ------------- | ------------ | ---------- | -------------------- | -------------------------------- | +| `22.13.1` | `>=20.0.0` | - | `22.13.1` | No (22.13.1 satisfies >=20) | +| `22.13.1` | `>=24.0.0` | - | `22.13.1` | **Yes** (22.13.1 < 24) | +| - | `>=20.0.0` | `^24.4.0` | latest matching >=20 | No (if resolved >= 24) | +| `20.18.0` | - | `^24.4.0` | `20.18.0` | **Yes** (20 doesn't satisfy ^24) | + ### Fallback Behavior -- If no `devEngines.runtime` is configured, downloads the latest Node.js version -- If `name` is not `"node"`, the runtime is skipped -- If `version` is empty, downloads the latest Node.js version +When no version source exists: -### Version Write-Back +1. Check local cache for installed Node.js versions +2. Use the **latest installed version** (if any exist) +3. If no cached versions exist, fetch and use latest LTS from network +4. Write the used version to `.node-version` +5. Print: `Using Node {version} - saved version to .node-version` -When `download_runtime_for_project` resolves a version (i.e., no version was specified), it writes the resolved version back to `package.json`. This ensures subsequent executions can skip version resolution and use the cached exact version directly. +This optimizes for: -**Write-back occurs when:** +- Avoiding unnecessary network requests +- Using what the user already has installed +- Establishing `.node-version` as the version source going forward -- `devEngines.runtime` doesn't exist (creates the entire structure) -- `devEngines.runtime` exists but has no `version` field -- `devEngines.runtime` is an array and the matching entry has no `version` field +### Version Write-Back -**Write-back does NOT occur when:** +When `download_runtime_for_project` resolves a version and **no version source exists**, it writes the resolved version to `.node-version`. This establishes a version source for future use. -- A version range is already specified (e.g., `^20.18.0`) -- An exact version is already specified (e.g., `20.18.0`) +**Write-back only occurs when no version source exists:** -**Example: Before download (no version specified)** +| Read From | Write To | Message | +| -------------------- | ---------------------- | ------------------------------------------------------- | +| `.node-version` | No write | - | +| `engines.node` | No write | - | +| `devEngines.runtime` | No write | - | +| No source | Create `.node-version` | "Using Node {version} - saved version to .node-version" | -```json -{ - "name": "my-project", - "devEngines": { - "runtime": { - "name": "node" - } - } -} -``` +**Key behaviors:** -**After download (version written back)** +1. Only write when no version source exists (respects user's explicit version requirements) +2. Use three-part version without `v` prefix with Unix line ending +3. Print informational message when saving version -```json -{ - "name": "my-project", - "devEngines": { - "runtime": { - "name": "node", - "version": "24.5.0" - } - } -} +**Example: Before download (no version source)** + +Project structure: + +``` +my-project/ +└── package.json ``` -**Formatting preservation:** +**After download (.node-version created)** -- Original indentation style is preserved (2 spaces, 4 spaces, or tabs) -- Key order is preserved using `serde_json` with `preserve_order` feature -- Trailing newline is preserved if present in original +Project structure: + +``` +my-project/ +├── .node-version # Contains: 24.5.0 +└── package.json +``` ## Download Sources @@ -657,13 +723,21 @@ pub enum Error { 9. ✅ Support semver ranges (^, ~, etc.) with version resolution 10. ✅ Version index caching with 1-hour TTL 11. ✅ Support both single runtime and array of runtimes in devEngines -12. ✅ Write resolved version back to package.json (with formatting preservation) +12. ✅ Write resolved version to `.node-version` file 13. ✅ Optimized version resolution (skip network for exact versions, check local cache for ranges) +14. ✅ Multi-source version reading with priority: `.node-version` > `engines.node` > `devEngines.runtime` +15. ✅ Support `.node-version` file format (with/without v prefix, partial versions) +16. ✅ Support `engines.node` from package.json +17. ✅ Warn when resolved version conflicts with lower-priority source constraints +18. ✅ Use latest cached version when no source specified (avoid network request) +19. ✅ Invalid version strings are ignored with warning, falling through to lower-priority sources ## References - [Node.js Releases](https://nodejs.org/en/download/releases/) - [Node.js Distribution Index](https://nodejs.org/dist/index.json) +- [.node-version file usage](https://github.com/shadowspawn/node-version-usage) +- [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md) - [Corepack (Node.js Package Manager Manager)](https://nodejs.org/api/corepack.html) - [fnm (Fast Node Manager)](https://github.com/Schniz/fnm) - [volta (JavaScript Tool Manager)](https://volta.sh/)