diff --git a/crates/vite_error/src/lib.rs b/crates/vite_error/src/lib.rs index 385432b48b..30fd98751a 100644 --- a/crates/vite_error/src/lib.rs +++ b/crates/vite_error/src/lib.rs @@ -89,6 +89,9 @@ pub enum Error { #[error("Unsupported package manager: {0}")] UnsupportedPackageManager(Str), + #[error("devEngines.packageManager {0:?} is not supported (supported: pnpm, yarn, npm, bun)")] + UnsupportedDevEnginesPackageManager(Str), + #[error("Unrecognized any package manager, please specify the package manager")] UnrecognizedPackageManager, diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index c48bad2b56..2ac03b2ba3 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -337,18 +337,24 @@ Examples: tool: String, }, - /// Pin a Node.js version in the current directory (creates .node-version) + /// Pin a Node.js version in the current directory + /// (updates .node-version or package.json#devEngines.runtime) #[command(after_long_help = "\ Examples: vp env pin lts # Pin to latest LTS - vp env pin --unpin # Remove .node-version - vp env pin \"^20.0.0\" --force # Overwrite existing pin")] + vp env pin --unpin # Remove the pin + vp env pin \"^20.0.0\" --force # Overwrite existing pin + vp env pin 24 --target node-version # Force the .node-version file + +The write target follows the compatibility-first rule: an existing .node-version +keeps being updated; otherwise the pin is written to package.json#devEngines.runtime; +.node-version is only created when the directory has no package.json.")] Pin { /// Version to pin (e.g., "20.18.0", "lts", "latest", "^20.0.0"). /// If omitted, prints the currently pinned version. version: Option, - /// Remove the .node-version file from current directory + /// Remove the pin from the current directory #[arg(long)] unpin: bool, @@ -356,13 +362,21 @@ Examples: #[arg(long)] no_install: bool, - /// Overwrite existing .node-version without confirmation + /// Overwrite an existing pin without confirmation #[arg(long)] force: bool, + + /// Explicitly choose the write target (overrides the default selection) + #[arg(long, value_enum)] + target: Option, }, - /// Remove the .node-version file from current directory (alias for `pin --unpin`) - Unpin, + /// Remove the Node.js pin from current directory (alias for `pin --unpin`) + Unpin { + /// Explicitly choose which pin source to remove + #[arg(long, value_enum)] + target: Option, + }, /// List locally installed Node.js versions #[command(visible_alias = "ls")] @@ -468,6 +482,15 @@ impl EnvSubcommands { } } +/// Write target for `vp env pin` / `vp env unpin` (see rfcs/dev-engines.md) +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum PinTarget { + /// Pin via the .node-version file + NodeVersion, + /// Pin via package.json#devEngines.runtime + DevEngines, +} + /// Version sorting order for list-remote command #[derive(clap::ValueEnum, Clone, Debug, Default)] pub enum SortingMethod { diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index 45e955a797..84b07acdd0 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -200,8 +200,8 @@ pub async fn delete_session_version() -> Result<(), Error> { /// 0. `VP_NODE_VERSION` env var (session override from `vp env use`) /// 1. `.session-node-version` file (session override written by `vp env use` for shell-wrapper-less environments) /// 2. `.node-version` file in current or parent directories -/// 3. `package.json#engines.node` in current or parent directories -/// 4. `package.json#devEngines.runtime` in current or parent directories +/// 3. `package.json#devEngines.runtime` in current or parent directories +/// 4. `package.json#engines.node` in current or parent directories /// 5. User default from config.json /// 6. Latest LTS version pub async fn resolve_version(cwd: &AbsolutePath) -> Result { @@ -267,27 +267,28 @@ pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result Result Result { // Section: Version Resolution print_section("Version Resolution"); - check_current_resolution(&cwd, shim_mode, system_node_path).await; + let resolved_version = check_current_resolution(&cwd, shim_mode, system_node_path).await; + + // Section: devEngines (conditional, see rfcs/dev-engines.md) + check_dev_engines(&cwd, resolved_version.as_deref()).await; // Section: Conflicts (conditional) check_conflicts(); @@ -515,12 +518,27 @@ fn print_ide_setup_guidance(bin_dir: &vite_path::AbsolutePath) { } } +/// Render the "Source" line for the resolved Node.js version. +/// +/// package.json holds both `engines.node` and `devEngines.runtime`, so the path +/// alone is ambiguous; name which field the version came from (matching +/// `vp env pin`'s output). Other sources (`.node-version`, session, default) are +/// already unambiguous from the path or label, so the bare path/label is shown. +fn format_version_source(source: &str, source_path: Option<&vite_path::AbsolutePath>) -> String { + let names_pkg_field = matches!(source, "devEngines.runtime" | "engines.node"); + match source_path { + Some(path) if names_pkg_field => format!("{} ({source})", path.as_path().display()), + Some(path) => path.as_path().display().to_string(), + None => source.to_string(), + } +} + /// Check current directory version resolution. async fn check_current_resolution( cwd: &AbsolutePathBuf, shim_mode: ShimMode, system_node_path: Option, -) { +) -> Option { print_check(" ", "Directory", &cwd.as_path().display().to_string()); // In system-first mode, show system Node.js info instead of managed resolution @@ -542,23 +560,20 @@ async fn check_current_resolution( ); print_hint("Install Node.js or run 'vp env on' to use managed Node.js."); } - return; + return None; } match resolve_version(cwd).await { Ok(resolution) => { - let source_display = resolution - .source_path - .as_ref() - .map(|p| p.as_path().display().to_string()) - .unwrap_or(resolution.source); + let source_display = + format_version_source(&resolution.source, resolution.source_path.as_deref()); print_check(" ", "Source", &source_display); print_check(" ", "Version", &resolution.version.bright_green().to_string()); // Check if Node.js is installed let home_dir = match vite_shared::get_vp_home() { Ok(d) => d.join("js_runtime").join("node").join(&resolution.version), - Err(_) => return, + Err(_) => return None, }; #[cfg(windows)] @@ -576,6 +591,7 @@ async fn check_current_resolution( ); print_hint("Version will be downloaded on first use."); } + Some(resolution.version) } Err(e) => { print_check( @@ -583,6 +599,7 @@ async fn check_current_resolution( "Resolution", &format!("failed: {e}").red().to_string(), ); + None } } } @@ -597,6 +614,323 @@ async fn get_node_version(node_path: &vite_path::AbsolutePath) -> String { } } +/// One devEngines doctor finding. +struct DevEnginesFinding { + /// true for a warning, false for an informational note + warn: bool, + key: &'static str, + message: String, + hint: Option, +} + +impl DevEnginesFinding { + fn warn(key: &'static str, message: String) -> Self { + Self { warn: true, key, message, hint: None } + } + + fn warn_with_hint(key: &'static str, message: String, hint: String) -> Self { + Self { warn: true, key, message, hint: Some(hint) } + } + + fn note(key: &'static str, message: String) -> Self { + Self { warn: false, key, message, hint: None } + } +} + +/// Find the nearest package.json walking up from `cwd`. +async fn find_nearest_package_json(cwd: &AbsolutePathBuf) -> Option<(AbsolutePathBuf, String)> { + let mut current = cwd.clone(); + loop { + let candidate = current.join("package.json"); + if let Ok(content) = tokio::fs::read_to_string(&candidate).await { + return Some((current, content)); + } + current = current.parent()?.to_absolute_path_buf(); + } +} + +/// Find the nearest `devEngines.runtime` node declaration walking up from `cwd` +/// (the declaration may live in an ancestor manifest, e.g. a monorepo root). +async fn find_nearest_dev_engines_node_version(cwd: &AbsolutePathBuf) -> Option { + let mut current = cwd.clone(); + loop { + if let Ok(content) = tokio::fs::read_to_string(current.join("package.json")).await + && let Ok(pkg) = serde_json::from_str::(&content) + && let Some(declared) = pkg.dev_engines_runtime("node").and_then(|d| d.version.clone()) + { + return Some(declared); + } + current = current.parent()?.to_absolute_path_buf(); + } +} + +/// Check devEngines declarations for conflicts and spec issues (rfcs/dev-engines.md). +/// +/// All checks are semver-aware: an exact version satisfying a declared range is +/// not a conflict. Findings are warnings or notes; they never fail the doctor run +/// and are never auto-fixed. +async fn check_dev_engines(cwd: &AbsolutePathBuf, resolved_version: Option<&str>) { + let findings = collect_dev_engines_findings(cwd, resolved_version).await; + if findings.is_empty() { + return; + } + + print_section("devEngines"); + for finding in findings { + if finding.warn { + print_check( + &output::WARN_SIGN.yellow().to_string(), + finding.key, + &finding.message.yellow().to_string(), + ); + } else { + print_check(" ", finding.key, &finding.message); + } + if let Some(hint) = finding.hint { + print_hint(&hint); + } + } +} + +/// Read the workspace-root package.json (raw + typed) when it differs from the +/// nearest one; `None` means "use the nearest package.json". See the call site +/// for why package-manager checks need the workspace root. +async fn read_workspace_root_doc( + cwd: &AbsolutePathBuf, + nearest_pkg_path: &AbsolutePathBuf, +) -> Option<(serde_json::Value, vite_shared::PackageJson)> { + let (workspace_root, _) = vite_workspace::find_workspace_root(cwd).ok()?; + let root_pkg_path = workspace_root.path.join("package.json"); + if &root_pkg_path == nearest_pkg_path { + return None; + } + let content = tokio::fs::read_to_string(&root_pkg_path).await.ok()?; + Some((serde_json::from_str(&content).ok()?, serde_json::from_str(&content).ok()?)) +} + +/// Collect the devEngines findings for the nearest package.json. +async fn collect_dev_engines_findings( + cwd: &AbsolutePathBuf, + resolved_version: Option<&str>, +) -> Vec { + let Some((pkg_dir, content)) = find_nearest_package_json(cwd).await else { + return Vec::new(); + }; + let Ok(raw) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + let Ok(pkg) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + + // Package-manager checks examine the WORKSPACE ROOT package.json: that is the + // file vp install reads for packageManager / devEngines.packageManager. In a + // monorepo it can be a different (higher) file than the nearest package.json + // used by the Node.js runtime checks above. + let nearest_pkg_path = pkg_dir.join("package.json"); + let root_doc = read_workspace_root_doc(cwd, &nearest_pkg_path).await; + let (pm_raw, pm_pkg): (&serde_json::Value, &vite_shared::PackageJson) = match &root_doc { + Some((root_raw, root_pkg)) => (root_raw, root_pkg), + None => (&raw, &pkg), + }; + + let mut findings: Vec = Vec::new(); + + let runtime_field = pkg.dev_engines.as_ref().and_then(|de| de.runtime.as_ref()); + let package_manager_field = + pm_pkg.dev_engines.as_ref().and_then(|de| de.package_manager.as_ref()); + + // .node-version vs devEngines.runtime (semver-aware: only exact .node-version + // values can conflict with a declared range). Both sides follow the resolution + // walk: the check fires only when a .node-version actually wins resolution, and + // the devEngines.runtime declaration may live in an ancestor manifest rather + // than the nearest package.json. + if let Ok(Some(resolution)) = vite_js_runtime::resolve_node_version(cwd, true).await + && resolution.source == vite_js_runtime::VersionSource::NodeVersionFile + && let Ok(version) = node_semver::Version::parse(&resolution.version) + && let Some(declared) = find_nearest_dev_engines_node_version(cwd).await + && let Ok(range) = node_semver::Range::parse(declared.as_str()) + && !range.satisfies(&version) + { + findings.push(DevEnginesFinding::warn( + "Runtime", + format!( + ".node-version ({node_version}) does not satisfy devEngines.runtime \"{declared}\"", + node_version = resolution.version + ), + )); + } + + // Resolved Node.js version vs engines.node + if let Some(resolved) = resolved_version + && let Some(engines_node) = pkg.engines.as_ref().and_then(|e| e.node.as_ref()) + && let Ok(version) = node_semver::Version::parse(resolved) + && let Ok(range) = node_semver::Range::parse(engines_node.as_str()) + && !range.satisfies(&version) + { + findings.push(DevEnginesFinding::warn( + "Runtime", + format!("resolved Node.js {resolved} does not satisfy engines.node \"{engines_node}\""), + )); + } + + // Invalid semver ranges in devEngines entries (the spec only allows semver + // range syntax; aliases like lts/* are not valid there) + for (field_name, field) in + [("runtime", runtime_field), ("packageManager", package_manager_field)] + { + let Some(field) = field else { continue }; + for entry in field.entries() { + if let Some(version) = &entry.version + && node_semver::Range::parse(version.as_str()).is_err() + { + findings.push(DevEnginesFinding::warn( + "Spec", + format!( + "devEngines.{field_name} version \"{version}\" for \"{name}\" is not a \ + valid semver range (see devEngines spec)", + name = entry.name + ), + )); + } + } + } + + // Runtimes Vite+ does not manage (informational) + if let Some(field) = runtime_field { + for entry in field.entries() { + if entry.name != "node" { + findings.push(DevEnginesFinding::note( + "Runtime", + format!( + "devEngines.runtime declares \"{}\", which is not managed by Vite+", + entry.name + ), + )); + } + } + } + + // packageManager field vs devEngines.packageManager consistency + if let Some(pm_field) = pm_raw.get("packageManager").and_then(serde_json::Value::as_str) + && let Some(field) = package_manager_field + && !field.entries().is_empty() + { + let (pm_name, pm_rest) = pm_field.split_once('@').unwrap_or((pm_field, "")); + let pm_version = pm_rest.split('+').next().unwrap_or(pm_rest); + let future_error_hint = "This will become an error in a future release.".to_string(); + match field.find_by_name(pm_name) { + None => { + let names = + field.entries().iter().map(|e| e.name.as_str()).collect::>().join(", "); + findings.push(DevEnginesFinding::warn_with_hint( + "PackageManager", + format!( + "packageManager is \"{pm_name}@{pm_version}\" but \ + devEngines.packageManager requires \"{names}\"" + ), + future_error_hint, + )); + } + Some(entry) => { + if let Some(required) = &entry.version + && let Ok(range) = node_semver::Range::parse(required.as_str()) + && let Ok(version) = node_semver::Version::parse(pm_version) + && !range.satisfies(&version) + { + findings.push(DevEnginesFinding::warn_with_hint( + "PackageManager", + format!( + "packageManager {pm_name}@{pm_version} does not satisfy \ + devEngines.packageManager \"{required}\"" + ), + future_error_hint, + )); + } + } + } + } + + // Unsupported devEngines.packageManager names. When a supported entry exists + // too, the unsupported one is skipped by design (an info note); otherwise it + // is the only declaration and warrants a warning. + if let Some(field) = package_manager_field { + let is_supported = |name: &str| vite_install::PackageManagerType::from_name(name).is_some(); + let has_supported = field.entries().iter().any(|e| is_supported(&e.name)); + for entry in field.entries().iter().filter(|e| !is_supported(&e.name)) { + let skipped = if has_supported { " and will be skipped" } else { "" }; + let message = format!( + "devEngines.packageManager \"{}\" is not supported{skipped} \ + (supported: pnpm, yarn, npm, bun)", + entry.name + ); + findings.push(if has_supported { + DevEnginesFinding::note("PackageManager", message) + } else { + DevEnginesFinding::warn("PackageManager", message) + }); + } + } + + // Malformed entries that lenient parsing skipped (raw JSON inspection): + // runtime entries come from the nearest package.json, packageManager entries + // from the workspace root package.json + if let Some(raw_dev_engines) = raw.get("devEngines").and_then(serde_json::Value::as_object) + && let Some(value) = raw_dev_engines.get("runtime") + { + collect_malformed_entry_findings("runtime", value, &mut findings); + } + if let Some(raw_dev_engines) = pm_raw.get("devEngines").and_then(serde_json::Value::as_object) + && let Some(value) = raw_dev_engines.get("packageManager") + { + collect_malformed_entry_findings("packageManager", value, &mut findings); + } + + findings +} + +/// Collect findings for devEngines entries that lenient parsing skipped or that +/// carry unknown `onFail` values. +fn collect_malformed_entry_findings( + field_name: &str, + value: &serde_json::Value, + findings: &mut Vec, +) { + let entries: Vec<&serde_json::Value> = match value { + serde_json::Value::Array(items) => items.iter().collect(), + other => vec![other], + }; + + for entry in entries { + let Some(obj) = entry.as_object() else { + findings.push(DevEnginesFinding::warn( + "Spec", + format!("devEngines.{field_name} entry is not an object and was ignored"), + )); + continue; + }; + let name = obj.get("name").and_then(serde_json::Value::as_str).unwrap_or("").trim(); + if name.is_empty() { + findings.push(DevEnginesFinding::warn( + "Spec", + format!("devEngines.{field_name} entry is missing \"name\" and was ignored"), + )); + continue; + } + if let Some(on_fail) = obj.get("onFail").and_then(serde_json::Value::as_str) + && vite_shared::OnFail::parse(on_fail).is_none() + { + findings.push(DevEnginesFinding::warn( + "Spec", + format!( + "devEngines.{field_name} entry \"{name}\" has unknown onFail \"{on_fail}\" \ + (expected: ignore, warn, error, download)" + ), + )); + } + } +} + /// Check for conflicts with other version managers. fn check_conflicts() { let mut conflicts = Vec::new(); @@ -657,6 +991,405 @@ mod tests { #[cfg(not(windows))] use crate::commands::shell::{ShellProfileKind, ShellProfileRoot}; + /// Test helper: write `files` into a temp project and collect devEngines findings. + async fn dev_engines_findings_for( + files: &[(&str, &str)], + resolved_version: Option<&str>, + ) -> Vec { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + for (name, content) in files { + tokio::fs::write(temp_path.join(*name), content).await.unwrap(); + } + collect_dev_engines_findings(&temp_path, resolved_version).await + } + + // npm-install-checks: "semver version is not in range" (via .node-version) + #[tokio::test] + async fn test_dev_engines_findings_node_version_conflict() { + let findings = dev_engines_findings_for( + &[ + (".node-version", "20.18.0\n"), + ( + "package.json", + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#, + ), + ], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert_eq!(findings[0].key, "Runtime"); + assert!( + findings[0].message.contains(".node-version (20.18.0) does not satisfy"), + "got: {}", + findings[0].message + ); + } + + // npm-install-checks: "semver version is in range" (semver-aware: an exact + // version satisfying the declared range is not a conflict) + #[tokio::test] + async fn test_dev_engines_findings_node_version_satisfies_range() { + let findings = dev_engines_findings_for( + &[ + (".node-version", "24.1.0\n"), + ( + "package.json", + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#, + ), + ], + None, + ) + .await; + + assert!(findings.is_empty(), "findings: {:?}", messages(&findings)); + } + + #[tokio::test] + async fn test_dev_engines_findings_resolved_violates_engines_node() { + let findings = dev_engines_findings_for( + &[("package.json", r#"{"engines":{"node":">=22.0.0"}}"#)], + Some("20.18.0"), + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert!( + findings[0].message.contains("resolved Node.js 20.18.0 does not satisfy engines.node"), + "got: {}", + findings[0].message + ); + } + + // npm-install-checks: "invalid name" + #[tokio::test] + async fn test_dev_engines_findings_package_manager_name_mismatch() { + let findings = dev_engines_findings_for( + &[( + "package.json", + r#"{ + "packageManager": "npm@10.5.0", + "devEngines": {"packageManager": {"name": "pnpm", "version": "^11.0.0"}} + }"#, + )], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert_eq!(findings[0].key, "PackageManager"); + assert!( + findings[0].message.contains("but devEngines.packageManager requires \"pnpm\""), + "got: {}", + findings[0].message + ); + assert!( + findings[0].hint.as_deref().unwrap_or_default().contains("error in a future release") + ); + } + + // npm-install-checks: "semver version is not in range" + #[tokio::test] + async fn test_dev_engines_findings_package_manager_version_not_satisfying() { + let findings = dev_engines_findings_for( + &[( + "package.json", + r#"{ + "packageManager": "pnpm@10.9.0", + "devEngines": {"packageManager": {"name": "pnpm", "version": "^11.0.0"}} + }"#, + )], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert!( + findings[0].message.contains("pnpm@10.9.0 does not satisfy"), + "got: {}", + findings[0].message + ); + assert!( + findings[0].hint.as_deref().unwrap_or_default().contains("error in a future release") + ); + } + + // npm-install-checks: "non-semver version" (npm compares by string equality; + // Vite+ flags the value as spec non-compliant instead) + #[tokio::test] + async fn test_dev_engines_findings_invalid_semver_range() { + let findings = dev_engines_findings_for( + &[("package.json", r#"{"devEngines":{"runtime":{"name":"node","version":"lts/*"}}}"#)], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert_eq!(findings[0].key, "Spec"); + assert!( + findings[0].message.contains("\"lts/*\" for \"node\" is not a valid semver range"), + "got: {}", + findings[0].message + ); + } + + // npm-install-checks: "unrecognized onFail" + #[tokio::test] + async fn test_dev_engines_findings_unknown_on_fail() { + let findings = dev_engines_findings_for( + &[( + "package.json", + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0","onFail":"unrecognized"}}}"#, + )], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert_eq!(findings[0].key, "Spec"); + assert!( + findings[0].message.contains("unknown onFail \"unrecognized\""), + "got: {}", + findings[0].message + ); + } + + // npm-install-checks: "missing name" + #[tokio::test] + async fn test_dev_engines_findings_missing_name() { + let findings = dev_engines_findings_for( + &[("package.json", r#"{"devEngines":{"packageManager":{"version":"^1.0.0"}}}"#)], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert_eq!(findings[0].key, "Spec"); + assert!( + findings[0].message.contains("missing \"name\" and was ignored"), + "got: {}", + findings[0].message + ); + } + + #[tokio::test] + async fn test_dev_engines_findings_non_node_runtime_note() { + let findings = dev_engines_findings_for( + &[("package.json", r#"{"devEngines":{"runtime":{"name":"deno","version":"^2.0.0"}}}"#)], + None, + ) + .await; + + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + // informational note, not a warning + assert!(!findings[0].warn); + assert!( + findings[0].message.contains("\"deno\", which is not managed by Vite+"), + "got: {}", + findings[0].message + ); + } + + #[tokio::test] + async fn test_dev_engines_findings_unsupported_package_manager_warn_vs_note() { + // alone: a warning (nothing usable declared) + let findings = dev_engines_findings_for( + &[( + "package.json", + r#"{"devEngines":{"packageManager":{"name":"vlt","version":"^1.0.0"}}}"#, + )], + None, + ) + .await; + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert!( + findings[0].message.contains("\"vlt\" is not supported"), + "got: {}", + findings[0].message + ); + + // alongside a supported entry: an informational note (it is skipped by design) + let findings = dev_engines_findings_for( + &[( + "package.json", + r#"{ + "devEngines": { + "packageManager": [ + {"name": "vlt", "version": "^1.0.0"}, + {"name": "pnpm", "version": "^11.0.0"} + ] + } + }"#, + )], + None, + ) + .await; + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(!findings[0].warn); + assert!( + findings[0].message.contains("\"vlt\" is not supported and will be skipped"), + "got: {}", + findings[0].message + ); + } + + #[tokio::test] + async fn test_dev_engines_findings_node_version_conflict_with_ancestor_manifest() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // The devEngines.runtime declaration lives in an ancestor manifest, not the + // nearest package.json + tokio::fs::write( + temp_path.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#, + ) + .await + .unwrap(); + let app_dir = temp_path.join("app"); + tokio::fs::create_dir_all(&app_dir).await.unwrap(); + tokio::fs::write(app_dir.join("package.json"), r#"{"name": "app"}"#).await.unwrap(); + tokio::fs::write(app_dir.join(".node-version"), "20.18.0\n").await.unwrap(); + + let findings = collect_dev_engines_findings(&app_dir, None).await; + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert!( + findings[0].message.contains(".node-version (20.18.0) does not satisfy"), + "got: {}", + findings[0].message + ); + } + + #[tokio::test] + async fn test_dev_engines_findings_no_conflict_when_dev_engines_wins_resolution() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // A parent .node-version that resolution never reaches: the nearer + // devEngines.runtime wins, so there is no effective conflict + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + let app_dir = temp_path.join("app"); + tokio::fs::create_dir_all(&app_dir).await.unwrap(); + tokio::fs::write( + app_dir.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#, + ) + .await + .unwrap(); + + let findings = collect_dev_engines_findings(&app_dir, None).await; + assert!(findings.is_empty(), "findings: {:?}", messages(&findings)); + } + + #[tokio::test] + async fn test_dev_engines_findings_package_manager_checks_use_workspace_root() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // monorepo root: the package.json vp install reads, with a PM conflict + tokio::fs::write(temp_path.join("pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n") + .await + .unwrap(); + tokio::fs::write( + temp_path.join("package.json"), + r#"{ + "name": "root", + "packageManager": "npm@10.5.0", + "devEngines": {"packageManager": {"name": "pnpm", "version": "^11.0.0"}} +} +"#, + ) + .await + .unwrap(); + + // nested package without any package-manager fields + let app_dir = temp_path.join("packages").join("app"); + tokio::fs::create_dir_all(&app_dir).await.unwrap(); + tokio::fs::write(app_dir.join("package.json"), r#"{"name": "app"}"#).await.unwrap(); + + // running from the nested package still diagnoses the workspace root's + // packageManager vs devEngines.packageManager conflict + let findings = collect_dev_engines_findings(&app_dir, None).await; + assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); + assert!(findings[0].warn); + assert_eq!(findings[0].key, "PackageManager"); + assert!( + findings[0].message.contains("but devEngines.packageManager requires \"pnpm\""), + "got: {}", + findings[0].message + ); + } + + // npm-install-checks: "spec 1" (everything declared and satisfied: no findings) + #[tokio::test] + async fn test_dev_engines_findings_all_satisfied() { + let findings = dev_engines_findings_for( + &[ + (".node-version", "24.1.0\n"), + ( + "package.json", + r#"{ + "engines": {"node": ">=20.0.0"}, + "packageManager": "yarn@3.2.3", + "devEngines": { + "runtime": {"name": "node", "version": ">= 20.0.0", "onFail": "error"}, + "packageManager": {"name": "yarn", "version": "3.2.3", "onFail": "download"} + } + }"#, + ), + ], + Some("24.1.0"), + ) + .await; + + assert!(findings.is_empty(), "findings: {:?}", messages(&findings)); + } + + /// Test helper: extract finding messages for assertion failure output. + fn messages(findings: &[DevEnginesFinding]) -> Vec<&str> { + findings.iter().map(|f| f.message.as_str()).collect() + } + + #[test] + fn test_format_version_source_distinguishes_package_json_fields() { + // a real (cross-platform) absolute path; assert against its own display + // string so the test holds on Windows too + let temp = TempDir::new().unwrap(); + let pkg = AbsolutePathBuf::new(temp.path().join("package.json")).unwrap(); + let pkg_str = pkg.as_path().display().to_string(); + // both fields live in package.json, so the field name must be shown + assert_eq!( + format_version_source("devEngines.runtime", Some(&pkg)), + format!("{pkg_str} (devEngines.runtime)") + ); + assert_eq!( + format_version_source("engines.node", Some(&pkg)), + format!("{pkg_str} (engines.node)") + ); + + // .node-version is already unambiguous from its path (no suffix appended) + let nv = AbsolutePathBuf::new(temp.path().join(".node-version")).unwrap(); + assert_eq!( + format_version_source(".node-version", Some(&nv)), + nv.as_path().display().to_string() + ); + + // pathless sources fall back to the label + assert_eq!(format_version_source("default", None), "default"); + assert_eq!(format_version_source("lts", None), "lts"); + } + #[test] fn test_shim_filename_consistency() { // All tools should use the same extension pattern diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index c4e8610592..e98a3c8c39 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -66,10 +66,10 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result doctor::execute(cwd).await, crate::cli::EnvSubcommands::Which { tool } => which::execute(cwd, &tool).await, - crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force } => { - pin::execute(cwd, version, unpin, no_install, force).await + crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force, target } => { + pin::execute(cwd, version, unpin, no_install, force, target).await } - crate::cli::EnvSubcommands::Unpin => unpin::execute(cwd).await, + crate::cli::EnvSubcommands::Unpin { target } => unpin::execute(cwd, target).await, crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await, crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => { list_remote::execute(pattern, lts, all, json, sort).await diff --git a/crates/vite_global_cli/src/commands/env/pin.rs b/crates/vite_global_cli/src/commands/env/pin.rs index 5f891dd05c..b6941e3c08 100644 --- a/crates/vite_global_cli/src/commands/env/pin.rs +++ b/crates/vite_global_cli/src/commands/env/pin.rs @@ -1,20 +1,30 @@ //! Pin command for per-directory Node.js version management. //! -//! Handles `vp env pin [VERSION]` to pin a Node.js version in the current directory -//! by creating or updating a `.node-version` file. - -use std::{io::Write, process::ExitStatus}; +//! Handles `vp env pin [VERSION]` to pin a Node.js version in the current directory. +//! The write target follows the compatibility-first rule from rfcs/dev-engines.md: +//! an existing `.node-version` keeps being updated; otherwise the pin is written to +//! `package.json#devEngines.runtime`; `.node-version` is only created when the +//! directory has no package.json. An explicit `--target` flag overrides the selection. +//! An existing `engines.node` is never deleted or modified. + +use std::{ + io::{IsTerminal, Write}, + process::ExitStatus, +}; use vite_js_runtime::NodeProvider; use vite_path::AbsolutePathBuf; use vite_shared::output; use super::config::{get_config_path, load_config}; -use crate::error::Error; +use crate::{cli::PinTarget, error::Error}; /// Node version file name const NODE_VERSION_FILE: &str = ".node-version"; +/// Package manifest file name +const PACKAGE_JSON_FILE: &str = "package.json"; + /// Execute the pin command. pub async fn execute( cwd: AbsolutePathBuf, @@ -22,14 +32,15 @@ pub async fn execute( unpin: bool, no_install: bool, force: bool, + target: Option, ) -> Result { // Handle --unpin flag if unpin { - return do_unpin(&cwd).await; + return do_unpin(&cwd, target).await; } match version { - Some(v) => do_pin(&cwd, &v, no_install, force).await, + Some(v) => do_pin(&cwd, &v, no_install, force, target).await, None => show_pinned(&cwd).await, } } @@ -47,10 +58,20 @@ async fn show_pinned(cwd: &AbsolutePathBuf) -> Result { return Ok(ExitStatus::default()); } + // Check devEngines.runtime in the current directory's package.json + if let Some(version) = read_dev_engines_node_version(cwd).await { + println!("Pinned version: {version}"); + println!( + " Source: {} (devEngines.runtime)", + cwd.join(PACKAGE_JSON_FILE).as_path().display() + ); + return Ok(ExitStatus::default()); + } + // Check for inherited version from parent directories - if let Some((version, source_path)) = find_inherited_version(cwd).await? { + if let Some((version, source)) = find_inherited_version(cwd).await? { println!("No version pinned in current directory."); - println!(" Inherited: {version} from {}", source_path.as_path().display()); + println!(" Inherited: {version} from {source}"); return Ok(ExitStatus::default()); } @@ -71,17 +92,29 @@ async fn show_pinned(cwd: &AbsolutePathBuf) -> Result { Ok(ExitStatus::default()) } -/// Find .node-version in parent directories. -async fn find_inherited_version( - cwd: &AbsolutePathBuf, -) -> Result, Error> { +/// Find an inherited pin (`.node-version` or `package.json#devEngines.runtime`) +/// in parent directories. +/// +/// Mirrors the resolution order within each directory: `.node-version` first, +/// then the devEngines.runtime node entry. Returns the version and a display +/// string describing the source. +async fn find_inherited_version(cwd: &AbsolutePathBuf) -> Result, Error> { let mut current: Option = cwd.parent().map(|p| p.to_absolute_path_buf()); while let Some(dir) = current { let node_version_path = dir.join(NODE_VERSION_FILE); if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { let content = tokio::fs::read_to_string(&node_version_path).await?; - return Ok(Some((content.trim().to_string(), node_version_path))); + return Ok(Some(( + content.trim().to_string(), + node_version_path.as_path().display().to_string(), + ))); + } + if let Some(version) = read_dev_engines_node_version(&dir).await { + return Ok(Some(( + version, + format!("{} (devEngines.runtime)", dir.join(PACKAGE_JSON_FILE).as_path().display()), + ))); } current = dir.parent().map(|p| p.to_absolute_path_buf()); } @@ -95,54 +128,62 @@ async fn do_pin( version: &str, no_install: bool, force: bool, + target: Option, ) -> Result { let provider = NodeProvider::new(); - let node_version_path = cwd.join(NODE_VERSION_FILE); // Resolve the version (aliases like lts/latest are resolved to exact versions) let (resolved_version, was_alias) = resolve_version_for_pin(version, &provider).await?; - // Check if .node-version already exists - if !force && tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { - let existing_content = tokio::fs::read_to_string(&node_version_path).await?; - let existing_version = existing_content.trim(); - - if existing_version == resolved_version { - println!("Already pinned to {resolved_version}"); - return Ok(ExitStatus::default()); - } - - // Prompt for confirmation - print!(".node-version already exists with version {existing_version}"); - println!(); - print!("Overwrite with {resolved_version}? (y/n): "); - std::io::stdout().flush()?; + let node_version_exists = + tokio::fs::try_exists(cwd.join(NODE_VERSION_FILE)).await.unwrap_or(false); + let package_json_exists = + tokio::fs::try_exists(cwd.join(PACKAGE_JSON_FILE)).await.unwrap_or(false); - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; + // Compatibility-first target selection (rfcs/dev-engines.md): an existing + // .node-version keeps winning; otherwise pin into package.json#devEngines.runtime; + // .node-version is only created when the directory has no package.json. + let target = target.unwrap_or(if node_version_exists || !package_json_exists { + PinTarget::NodeVersion + } else { + PinTarget::DevEngines + }); - if !input.trim().eq_ignore_ascii_case("y") { - println!("Cancelled."); - return Ok(ExitStatus::default()); + let pinned = match target { + PinTarget::NodeVersion => { + pin_node_version_file(cwd, version, &resolved_version, was_alias, force).await? } - } + PinTarget::DevEngines => { + if !package_json_exists { + return Err(Error::ConfigError( + format!( + "cannot pin to devEngines: no {} in {}", + PACKAGE_JSON_FILE, + cwd.as_path().display() + ) + .into(), + )); + } + let pinned = pin_dev_engines(cwd, version, &resolved_version, was_alias, force).await?; + // .node-version still wins resolution, so warn that the devEngines pin + // is shadowed until it is removed + if pinned && node_version_exists { + output::warn(&format!( + "{NODE_VERSION_FILE} still takes precedence over devEngines.runtime. Remove \ + it with 'vp env unpin --target node-version' if devEngines should win." + )); + } + pinned + } + }; - // Write the version to .node-version - tokio::fs::write(&node_version_path, format!("{resolved_version}\n")).await?; + if !pinned { + return Ok(ExitStatus::default()); + } // Invalidate resolve cache so the pinned version takes effect immediately crate::shim::invalidate_cache(); - // Print success message - if was_alias { - output::success(&format!( - "Pinned Node.js version to {resolved_version} (resolved from {version})" - )); - } else { - output::success(&format!("Pinned Node.js version to {resolved_version}")); - } - println!(" Created {} in {}", NODE_VERSION_FILE, cwd.as_path().display()); - // Pre-download the version unless --no-install is specified if no_install { output::note("Version will be downloaded on first use."); @@ -167,6 +208,297 @@ async fn do_pin( Ok(ExitStatus::default()) } +/// Confirm overwriting an existing pin with a different version. +/// +/// Returns `false` when the pin is already at `resolved_version` or when the +/// user declines the overwrite prompt (`force` skips the prompt only). +fn confirm_overwrite_pin( + source_label: &str, + existing_version: &str, + resolved_version: &str, + force: bool, +) -> Result { + if existing_version == resolved_version { + println!("Already pinned to {resolved_version}"); + return Ok(false); + } + if force { + return Ok(true); + } + + // Prompt for confirmation, defaulting to yes (the user explicitly asked to + // pin a new version, so only an explicit "no" cancels) + print!("{source_label} {existing_version}"); + println!(); + print!("Overwrite with {resolved_version}? (Y/n): "); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + let answer = input.trim(); + if answer.eq_ignore_ascii_case("n") || answer.eq_ignore_ascii_case("no") { + println!("Cancelled."); + return Ok(false); + } + Ok(true) +} + +/// Print the pin success message. +fn print_pin_success(input_version: &str, resolved_version: &str, was_alias: bool) { + if was_alias { + output::success(&format!( + "Pinned Node.js version to {resolved_version} (resolved from {input_version})" + )); + } else { + output::success(&format!("Pinned Node.js version to {resolved_version}")); + } +} + +/// Pin by writing the `.node-version` file. +/// +/// Returns `true` when the pin was written, `false` when nothing changed +/// (already pinned, or the user cancelled). +async fn pin_node_version_file( + cwd: &AbsolutePathBuf, + input_version: &str, + resolved_version: &str, + was_alias: bool, + force: bool, +) -> Result { + let node_version_path = cwd.join(NODE_VERSION_FILE); + + // Check if .node-version already exists + if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { + let existing_content = tokio::fs::read_to_string(&node_version_path).await?; + let existing_version = existing_content.trim(); + if !confirm_overwrite_pin( + ".node-version already exists with version", + existing_version, + resolved_version, + force, + )? { + return Ok(false); + } + } + + // Write the version to .node-version + tokio::fs::write(&node_version_path, format!("{resolved_version}\n")).await?; + + print_pin_success(input_version, resolved_version, was_alias); + println!(" Created {} in {}", NODE_VERSION_FILE, cwd.as_path().display()); + + // If a devEngines.runtime range is declared and no longer satisfied, offer to + // sync it in interactive terminals and warn otherwise (rfcs/dev-engines.md) + check_dev_engines_sync(cwd, resolved_version, force).await?; + + Ok(true) +} + +/// Pin by writing `package.json#devEngines.runtime`. +/// +/// Returns `true` when the pin was written, `false` when nothing changed +/// (already pinned, or the user cancelled). +async fn pin_dev_engines( + cwd: &AbsolutePathBuf, + input_version: &str, + resolved_version: &str, + was_alias: bool, + force: bool, +) -> Result { + let package_json_path = cwd.join(PACKAGE_JSON_FILE); + + if let Some(existing_version) = read_dev_engines_node_version(cwd).await + && !confirm_overwrite_pin( + "devEngines.runtime already set to", + &existing_version, + resolved_version, + force, + )? + { + return Ok(false); + } + + write_dev_engines_node_version(cwd, resolved_version).await?; + + print_pin_success(input_version, resolved_version, was_alias); + println!(" Updated devEngines.runtime in {}", package_json_path.as_path().display()); + + Ok(true) +} + +/// Read the devEngines.runtime node entry version from the current directory's +/// package.json. +async fn read_dev_engines_node_version(cwd: &AbsolutePathBuf) -> Option { + let content = tokio::fs::read_to_string(cwd.join(PACKAGE_JSON_FILE)).await.ok()?; + let pkg: vite_shared::PackageJson = serde_json::from_str(&content).ok()?; + Some(pkg.dev_engines_runtime("node")?.version.clone()?.to_string()) +} + +/// After updating `.node-version`, check the declared devEngines.runtime range: +/// if the new version no longer satisfies it, offer to sync in interactive +/// terminals and warn otherwise (rfcs/dev-engines.md). Never syncs silently. +async fn check_dev_engines_sync( + cwd: &AbsolutePathBuf, + resolved_version: &str, + force: bool, +) -> Result<(), Error> { + let Some(declared) = read_dev_engines_node_version(cwd).await else { + return Ok(()); + }; + // An invalid declared range is reported by `vp env doctor`, not here + let Ok(range) = node_semver::Range::parse(&declared) else { + return Ok(()); + }; + let Ok(version) = node_semver::Version::parse(resolved_version) else { + return Ok(()); + }; + if range.satisfies(&version) { + return Ok(()); + } + + if std::io::stdin().is_terminal() && !force { + print!( + "devEngines.runtime (\"{declared}\") is no longer satisfied. Update it to \ + {resolved_version}? (y/n): " + ); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + if input.trim().eq_ignore_ascii_case("y") { + write_dev_engines_node_version(cwd, resolved_version).await?; + output::success(&format!("Updated devEngines.runtime to {resolved_version}")); + return Ok(()); + } + } + + output::warn(&format!( + "Node.js {resolved_version} does not satisfy devEngines.runtime \"{declared}\". Run \ + 'vp env doctor' for details." + )); + Ok(()) +} + +/// Create a new devEngines.runtime node entry. +fn new_node_entry(version: &str) -> serde_json::Value { + vite_shared::dev_engine_entry("node", version) +} + +/// Write the node entry version into `package.json#devEngines.runtime`, +/// preserving formatting, key order, sibling runtime entries, and any existing +/// `onFail` value. An existing `engines.node` is never touched; a newly created +/// `devEngines` is placed right after `engines` when present. +async fn write_dev_engines_node_version(cwd: &AbsolutePathBuf, version: &str) -> Result<(), Error> { + let package_json_path = cwd.join(PACKAGE_JSON_FILE); + let content = tokio::fs::read_to_string(&package_json_path).await?; + let updated = vite_shared::edit_json_object(&content, |obj| { + set_dev_engines_runtime_node(obj, version); + }) + .map_err(|e| Error::ConfigError(format!("failed to update package.json: {e}").into()))?; + tokio::fs::write(&package_json_path, updated).await?; + Ok(()) +} + +/// Set the node entry version inside a parsed package.json object. +fn set_dev_engines_runtime_node( + obj: &mut serde_json::Map, + version: &str, +) { + use serde_json::Value; + + let Some(dev_engines) = obj.get_mut("devEngines").and_then(Value::as_object_mut) else { + // No (object-shaped) devEngines yet: create it next to engines + vite_shared::insert_after( + obj, + "engines", + "devEngines", + serde_json::json!({ "runtime": new_node_entry(version) }), + ); + return; + }; + + let Some(runtime) = dev_engines.get_mut("runtime") else { + dev_engines.insert("runtime".into(), new_node_entry(version)); + return; + }; + + match runtime { + // Single node entry: update its version in place (preserves onFail) + Value::Object(entry) if entry.get("name").and_then(Value::as_str) == Some("node") => { + entry.insert("version".into(), Value::String(version.to_string())); + } + // Single entry for another runtime: convert to array form and append node + Value::Object(_) => { + let existing = std::mem::take(runtime); + *runtime = Value::Array(vec![existing, new_node_entry(version)]); + } + // Array form: update the existing node entry or append one + Value::Array(entries) => { + if let Some(entry) = entries + .iter_mut() + .filter_map(Value::as_object_mut) + .find(|entry| entry.get("name").and_then(Value::as_str) == Some("node")) + { + entry.insert("version".into(), Value::String(version.to_string())); + } else { + entries.push(new_node_entry(version)); + } + } + // Malformed value: replace with a single node entry + _ => { + *runtime = new_node_entry(version); + } + } +} + +/// Remove the node entry from `package.json#devEngines.runtime`. +/// +/// Cleans up an emptied `runtime` array and an emptied `devEngines` object. +/// Returns `true` when an entry was removed. +async fn remove_dev_engines_runtime_node(cwd: &AbsolutePathBuf) -> Result { + let package_json_path = cwd.join(PACKAGE_JSON_FILE); + if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + return Ok(false); + } + let content = tokio::fs::read_to_string(&package_json_path).await?; + let mut removed = false; + let updated = vite_shared::edit_json_object(&content, |obj| { + use serde_json::Value; + + let Some(dev_engines) = obj.get_mut("devEngines").and_then(Value::as_object_mut) else { + return; + }; + match dev_engines.get_mut("runtime") { + Some(Value::Object(entry)) + if entry.get("name").and_then(Value::as_str) == Some("node") => + { + dev_engines.remove("runtime"); + removed = true; + } + Some(Value::Array(entries)) => { + let before = entries.len(); + entries.retain(|entry| entry.get("name").and_then(Value::as_str) != Some("node")); + removed = entries.len() != before; + if entries.is_empty() { + dev_engines.remove("runtime"); + } + } + _ => {} + } + if removed && dev_engines.is_empty() { + obj.remove("devEngines"); + } + }) + .map_err(|e| Error::ConfigError(format!("failed to update package.json: {e}").into()))?; + + if removed { + tokio::fs::write(&package_json_path, updated).await?; + } + Ok(removed) +} + /// Resolve version for pinning. /// /// Aliases (lts, latest) are resolved to exact versions. @@ -199,21 +531,56 @@ async fn resolve_version_for_pin( } } -/// Remove the .node-version file from current directory. -pub async fn do_unpin(cwd: &AbsolutePathBuf) -> Result { +/// Remove the Node.js pin from the current directory. +/// +/// Removes the same source that `vp env pin` would write: `.node-version` when +/// present, otherwise the node entry from `package.json#devEngines.runtime`. +/// An explicit `target` overrides the selection. +pub async fn do_unpin( + cwd: &AbsolutePathBuf, + target: Option, +) -> Result { let node_version_path = cwd.join(NODE_VERSION_FILE); + let node_version_exists = tokio::fs::try_exists(&node_version_path).await.unwrap_or(false); - if !tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { - println!("No {} file in current directory.", NODE_VERSION_FILE); - return Ok(ExitStatus::default()); - } + let target = target.unwrap_or(if node_version_exists { + PinTarget::NodeVersion + } else { + PinTarget::DevEngines + }); + + match target { + PinTarget::NodeVersion => { + if !node_version_exists { + println!("No {NODE_VERSION_FILE} file in current directory."); + return Ok(ExitStatus::default()); + } - tokio::fs::remove_file(&node_version_path).await?; + tokio::fs::remove_file(&node_version_path).await?; - // Invalidate resolve cache so the unpinned version falls back correctly - crate::shim::invalidate_cache(); + // Invalidate resolve cache so the unpinned version falls back correctly + crate::shim::invalidate_cache(); - output::success(&format!("Removed {} from {}", NODE_VERSION_FILE, cwd.as_path().display())); + output::success(&format!( + "Removed {} from {}", + NODE_VERSION_FILE, + cwd.as_path().display() + )); + } + PinTarget::DevEngines => { + if remove_dev_engines_runtime_node(cwd).await? { + // Invalidate resolve cache so the unpinned version falls back correctly + crate::shim::invalidate_cache(); + + output::success(&format!( + "Removed devEngines.runtime node entry from {}", + cwd.join(PACKAGE_JSON_FILE).as_path().display() + )); + } else { + println!("No Node.js pin found in current directory."); + } + } + } Ok(ExitStatus::default()) } @@ -266,6 +633,49 @@ mod tests { assert_eq!(version, "20.18.0"); } + #[tokio::test] + async fn test_find_inherited_version_from_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Ancestor pin declared via devEngines.runtime instead of .node-version + tokio::fs::write( + temp_path.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#, + ) + .await + .unwrap(); + + let subdir = temp_path.join("subdir"); + tokio::fs::create_dir(&subdir).await.unwrap(); + + let (version, source) = find_inherited_version(&subdir).await.unwrap().unwrap(); + assert_eq!(version, "^24.0.0"); + assert!(source.ends_with("package.json (devEngines.runtime)"), "got: {source}"); + } + + #[tokio::test] + async fn test_find_inherited_version_node_version_wins_over_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Same directory declares both: .node-version wins (resolution order) + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + tokio::fs::write( + temp_path.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#, + ) + .await + .unwrap(); + + let subdir = temp_path.join("subdir"); + tokio::fs::create_dir(&subdir).await.unwrap(); + + let (version, source) = find_inherited_version(&subdir).await.unwrap().unwrap(); + assert_eq!(version, "20.18.0"); + assert!(source.ends_with(".node-version"), "got: {source}"); + } + #[tokio::test] async fn test_do_unpin() { let temp_dir = TempDir::new().unwrap(); @@ -276,7 +686,7 @@ mod tests { tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); // Unpin - let result = do_unpin(&temp_path).await; + let result = do_unpin(&temp_path, None).await; assert!(result.is_ok()); // File should be gone @@ -308,7 +718,7 @@ mod tests { // Create .node-version and unpin let node_version_path = temp_path.join(".node-version"); tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); - let result = do_unpin(&temp_path).await; + let result = do_unpin(&temp_path, None).await; assert!(result.is_ok()); // Cache file should be removed by invalidate_cache() @@ -346,7 +756,7 @@ mod tests { ); // Pin an exact version (no_install=true to skip download, force=true to skip prompt) - let result = do_pin(&temp_path, "20.18.0", true, true).await; + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; assert!(result.is_ok()); // .node-version should be created @@ -373,10 +783,253 @@ mod tests { let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); // Should not error when no file exists - let result = do_unpin(&temp_path).await; + let result = do_unpin(&temp_path, None).await; assert!(result.is_ok()); } + #[tokio::test] + async fn test_do_pin_targets_dev_engines_when_package_json_exists() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // package.json without .node-version: the pin goes into devEngines.runtime + tokio::fs::write( + temp_path.join("package.json"), + "{\n \"name\": \"test\",\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n", + ) + .await + .unwrap(); + + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); + + // .node-version is NOT created + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + let entry = &pkg["devEngines"]["runtime"]; + assert_eq!(entry["name"].as_str().unwrap(), "node"); + assert_eq!(entry["version"].as_str().unwrap(), "20.18.0"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // existing engines.node is kept unchanged + assert_eq!(pkg["engines"]["node"].as_str().unwrap(), ">=18.0.0"); + // devEngines is placed right after engines + let keys: Vec<&str> = pkg.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys, ["name", "engines", "devEngines"]); + } + + #[tokio::test] + async fn test_do_pin_keeps_node_version_file_target_when_it_exists() { + 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"), "18.20.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") + .await + .unwrap(); + + // force=true skips the overwrite prompt + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); + + // .node-version keeps winning for writes (compatibility-first) + let content = tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(content.trim(), "20.18.0"); + + // package.json is untouched + let pkg = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert!(!pkg.contains("devEngines")); + } + + #[tokio::test] + async fn test_do_pin_explicit_dev_engines_target_wins_over_node_version_file() { + 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"), "18.20.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") + .await + .unwrap(); + + let result = do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; + assert!(result.is_ok()); + + // devEngines is written; .node-version stays untouched (a warning is printed) + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(pkg["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + let node_version = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version.trim(), "18.20.0"); + } + + #[tokio::test] + async fn test_do_pin_dev_engines_target_requires_package_json() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let result = do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; + assert!(result.is_err()); + } + + #[test] + fn test_set_dev_engines_runtime_node_updates_in_place_preserving_on_fail() { + let content = r#"{ + "devEngines": { + "runtime": { + "name": "node", + "version": "^22.0.0", + "onFail": "error" + } + } +} +"#; + let updated = vite_shared::edit_json_object(content, |obj| { + set_dev_engines_runtime_node(obj, "24.1.0"); + }) + .unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let entry = &pkg["devEngines"]["runtime"]; + assert_eq!(entry["version"].as_str().unwrap(), "24.1.0"); + // the existing onFail is preserved + assert_eq!(entry["onFail"].as_str().unwrap(), "error"); + } + + #[test] + fn test_set_dev_engines_runtime_node_converts_other_runtime_to_array() { + let content = r#"{"devEngines":{"runtime":{"name":"deno","version":"^2.0.0"}}}"#; + let updated = vite_shared::edit_json_object(content, |obj| { + set_dev_engines_runtime_node(obj, "24.1.0"); + }) + .unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let entries = pkg["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + // the existing deno entry stays first + assert_eq!(entries[0]["name"].as_str().unwrap(), "deno"); + assert_eq!(entries[1]["name"].as_str().unwrap(), "node"); + assert_eq!(entries[1]["version"].as_str().unwrap(), "24.1.0"); + } + + #[test] + fn test_set_dev_engines_runtime_node_updates_array_entry() { + let content = r#"{ + "devEngines": { + "runtime": [ + {"name": "deno", "version": "^2.0.0"}, + {"name": "node", "version": "^22.0.0", "onFail": "warn"} + ] + } +} +"#; + let updated = vite_shared::edit_json_object(content, |obj| { + set_dev_engines_runtime_node(obj, "24.1.0"); + }) + .unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let entries = pkg["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[1]["version"].as_str().unwrap(), "24.1.0"); + assert_eq!(entries[1]["onFail"].as_str().unwrap(), "warn"); + } + + #[tokio::test] + async fn test_do_unpin_dev_engines_default_when_no_node_version_file() { + 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#"{ + "name": "test", + "devEngines": { + "runtime": {"name": "node", "version": "^24.0.0"} + } +} +"#, + ) + .await + .unwrap(); + + let result = do_unpin(&temp_path, None).await; + assert!(result.is_ok()); + + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + // the emptied devEngines object is cleaned up entirely + assert!(pkg.get("devEngines").is_none()); + assert_eq!(pkg["name"].as_str().unwrap(), "test"); + } + + #[tokio::test] + async fn test_remove_dev_engines_runtime_node_keeps_other_entries() { + 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": "deno", "version": "^2.0.0"}, + {"name": "node", "version": "^24.0.0"} + ], + "packageManager": {"name": "pnpm", "version": "^11.0.0"} + } +} +"#, + ) + .await + .unwrap(); + + let removed = remove_dev_engines_runtime_node(&temp_path).await.unwrap(); + assert!(removed); + + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + // other runtime entries and the packageManager entry are preserved + let entries = pkg["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"].as_str().unwrap(), "deno"); + assert_eq!(pkg["devEngines"]["packageManager"]["name"].as_str().unwrap(), "pnpm"); + } + + #[tokio::test] + async fn test_check_dev_engines_sync_warns_without_tty() { + 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":"^24.0.0"}}}"#, + ) + .await + .unwrap(); + + // 20.18.0 does not satisfy ^24.0.0: without a TTY this warns and never + // rewrites the declared range + check_dev_engines_sync(&temp_path, "20.18.0", false).await.unwrap(); + + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(pkg["devEngines"]["runtime"]["version"].as_str().unwrap(), "^24.0.0"); + } + + #[tokio::test] + async fn test_check_dev_engines_sync_noop_when_satisfied() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let original = r#"{"devEngines":{"runtime":{"name":"node","version":"^24.0.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), original).await.unwrap(); + + check_dev_engines_sync(&temp_path, "24.2.0", false).await.unwrap(); + + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(content, original); + } + #[tokio::test] async fn test_resolve_version_for_pin_partial_version() { let provider = NodeProvider::new(); diff --git a/crates/vite_global_cli/src/commands/env/unpin.rs b/crates/vite_global_cli/src/commands/env/unpin.rs index cb492467f6..5f6cd6203b 100644 --- a/crates/vite_global_cli/src/commands/env/unpin.rs +++ b/crates/vite_global_cli/src/commands/env/unpin.rs @@ -1,14 +1,16 @@ //! Unpin command - alias for `pin --unpin`. //! -//! Handles `vp env unpin` to remove the `.node-version` file from the current directory. +//! Handles `vp env unpin` to remove the Node.js pin from the current directory +//! (`.node-version` when present, otherwise the node entry from +//! `package.json#devEngines.runtime`). use std::process::ExitStatus; use vite_path::AbsolutePathBuf; -use crate::error::Error; +use crate::{cli::PinTarget, error::Error}; /// Execute the unpin command. -pub async fn execute(cwd: AbsolutePathBuf) -> Result { - super::pin::do_unpin(&cwd).await +pub async fn execute(cwd: AbsolutePathBuf, target: Option) -> Result { + super::pin::do_unpin(&cwd, target).await } diff --git a/crates/vite_global_cli/src/commands/version.rs b/crates/vite_global_cli/src/commands/version.rs index 6d420c1a0b..68fcdd2bc7 100644 --- a/crates/vite_global_cli/src/commands/version.rs +++ b/crates/vite_global_cli/src/commands/version.rs @@ -191,7 +191,14 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { .and_then(|(root, _)| { get_package_manager_type_and_version(&root, None) .ok() - .map(|(pm, v, _)| format!("{pm} v{v}")) + // a devEngines range (e.g. "^11.0.0") has no meaningful "v" prefix + .map(|(pm, v, _, _)| { + if v.starts_with(|c: char| c.is_ascii_digit()) { + format!("{pm} v{v}") + } else { + format!("{pm} {v}") + } + }) }) .unwrap_or(NOT_FOUND.to_string()); diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index 4beda23087..385adf5b59 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -514,13 +514,10 @@ fn env_help_doc() -> HelpDoc { "Manage", vec![ row("default", "Set or show the global default Node.js version"), - row( - "pin", - "Pin a Node.js version in the current directory (creates .node-version)", - ), + row("pin", "Pin a Node.js version in the current directory"), row( "unpin", - "Remove the .node-version file from current directory (alias for `pin --unpin`)", + "Remove the Node.js pin from the current directory (alias for `pin --unpin`)", ), row("use", "Use a specific Node.js version for this shell session"), row("install", "Install a Node.js version [aliases: i]"), diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 3d1e7f5806..d97c175023 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -485,12 +485,9 @@ async fn has_valid_version_source( let dev_engines_valid = !engines_valid && pkg - .dev_engines - .as_ref() - .and_then(|de| de.runtime.as_ref()) - .and_then(|rt| rt.find_by_name("node")) - .filter(|r| !r.version.is_empty()) - .is_some_and(|r| is_valid_version(&r.version)); + .dev_engines_runtime("node") + .and_then(|r| r.version.as_ref()) + .is_some_and(|v| is_valid_version(v)); Ok(engines_valid || dev_engines_valid) } diff --git a/crates/vite_install/src/config.rs b/crates/vite_install/src/config.rs index f0ba6c6021..427db4de18 100644 --- a/crates/vite_install/src/config.rs +++ b/crates/vite_install/src/config.rs @@ -21,6 +21,13 @@ pub fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> String { format!("{registry}/{name}/{version_or_tag}") } +/// Get the metadata url of a npm package (lists all published versions) +#[must_use] +pub fn get_npm_package_metadata_url(name: &str) -> String { + let registry = npm_registry(); + format!("{registry}/{name}") +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/vite_install/src/lib.rs b/crates/vite_install/src/lib.rs index c7e5f13db3..3f9a2adde5 100644 --- a/crates/vite_install/src/lib.rs +++ b/crates/vite_install/src/lib.rs @@ -13,6 +13,6 @@ pub mod request; mod shim; pub use package_manager::{ - PackageManager, PackageManagerType, download_package_manager, + PackageManager, PackageManagerSource, PackageManagerType, download_package_manager, get_package_manager_type_and_version, }; diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index b4effdfe11..5d8aa1c1ff 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -18,13 +18,14 @@ use serde::{Deserialize, Serialize}; use tokio::fs::remove_dir_all; use vite_error::Error; use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_shared::OnFail; use vite_str::Str; #[cfg(test)] use vite_workspace::find_package_root; use vite_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; use crate::{ - config::{get_npm_package_tgz_url, get_npm_package_version_url}, + config::{get_npm_package_metadata_url, get_npm_package_tgz_url, get_npm_package_version_url}, request::{HttpClient, download_and_extract_tgz_with_hash}, shim, }; @@ -72,6 +73,21 @@ impl PackageManagerType { } } + /// Parse a package manager name (no aliases) into a supported type. + /// + /// Unlike [`Self::from_tool`], this only accepts the canonical package + /// manager names (`pnpm`, `yarn`, `npm`, `bun`), not invocation aliases. + #[must_use] + pub fn from_name(name: &str) -> Option { + match name { + "pnpm" => Some(Self::Pnpm), + "yarn" => Some(Self::Yarn), + "npm" => Some(Self::Npm), + "bun" => Some(Self::Bun), + _ => None, + } + } + /// Resolve the bin file name for an invoked tool, preserving alias names /// that the managed PM installs alongside its primary binary. #[must_use] @@ -100,6 +116,19 @@ pub struct PackageManagerResolution { pub project_root: AbsolutePathBuf, } +/// Where the package manager selection came from (see rfcs/dev-engines.md). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageManagerSource { + /// Top-level `packageManager` field in package.json + PackageManagerField, + /// `devEngines.packageManager` field in package.json + DevEnginesPackageManager, + /// Lockfiles or package-manager config files + LockfileOrConfig, + /// Caller-provided default + Default, +} + // TODO(@fengmk2): should move ResolveCommandResult to vite-common crate #[derive(Debug)] pub struct ResolveCommandResult { @@ -145,18 +174,28 @@ impl PackageManagerBuilder { /// Detect the package manager from the current working directory. pub async fn build(&self) -> Result { let (workspace_root, _cwd) = find_workspace_root(&self.cwd)?; - let (package_manager_type, version_or_latest, hash) = + let (package_manager_type, version_or_req, hash, source) = get_package_manager_type_and_version(&workspace_root, self.client_override)?; // only download the package manager if it's not already downloaded let (install_dir, package_name, version) = - download_package_manager(package_manager_type, &version_or_latest, hash.as_deref()) + download_package_manager(package_manager_type, &version_or_req, hash.as_deref()) .await?; - if version_or_latest != version { - // auto set `packageManager` field in package.json + // Auto-pin the resolved version when detection had no explicit field + // (lockfiles, config files, or caller default). A devEngines range is + // the user's source of truth and is never frozen into an exact pin. + // See rfcs/dev-engines.md. + if matches!(source, PackageManagerSource::LockfileOrConfig | PackageManagerSource::Default) + && version_or_req != version + { let package_json_path = workspace_root.path.join("package.json"); - set_package_manager_field(&package_json_path, package_manager_type, &version).await?; + set_dev_engines_package_manager_field( + &package_json_path, + package_manager_type, + &version, + ) + .await?; } let is_monorepo = matches!( @@ -203,90 +242,119 @@ impl PackageManager { } } -/// Get the package manager name, version and optional hash from the workspace root. +/// Get the package manager name, version, optional hash, and detection source +/// from the workspace root. +/// +/// The returned version is exact when detected from the `packageManager` field, +/// `"latest"` when detected from lockfiles/config files/default, and may be a +/// semver range (or `"*"` for an absent version) when detected from +/// `devEngines.packageManager` (see rfcs/dev-engines.md). pub fn get_package_manager_type_and_version( workspace_root: &WorkspaceRoot, default: Option, -) -> Result<(PackageManagerType, Str, Option), Error> { +) -> Result<(PackageManagerType, Str, Option, PackageManagerSource), Error> { // check packageManager field in package.json if let Some(resolution) = get_package_manager_from_package_json(workspace_root)? { - return Ok((resolution.package_manager_type, resolution.version, resolution.hash)); + warn_on_dev_engines_package_manager_conflict(workspace_root, &resolution); + return Ok(( + resolution.package_manager_type, + resolution.version, + resolution.hash, + PackageManagerSource::PackageManagerField, + )); } - // TODO(@fengmk2): check devEngines.packageManager field in package.json + // check devEngines.packageManager field in package.json (see rfcs/dev-engines.md) + if let Some((package_manager_type, version_req)) = + get_package_manager_from_dev_engines(workspace_root)? + { + // an absent version means any version satisfies (devEngines spec) + let version_req = version_req.unwrap_or_else(|| "*".into()); + return Ok(( + package_manager_type, + version_req, + None, + PackageManagerSource::DevEnginesPackageManager, + )); + } let version = Str::from("latest"); + let source = PackageManagerSource::LockfileOrConfig; // if pnpm-workspace.yaml exists, use pnpm@latest if matches!(workspace_root.workspace_file, WorkspaceFile::PnpmWorkspaceYaml(_)) { - return Ok((PackageManagerType::Pnpm, version, None)); + return Ok((PackageManagerType::Pnpm, version, None, source)); } // if pnpm-lock.yaml exists, use pnpm@latest let pnpm_lock_yaml_path = workspace_root.path.join("pnpm-lock.yaml"); if is_exists_file(&pnpm_lock_yaml_path)? { - return Ok((PackageManagerType::Pnpm, version, None)); + return Ok((PackageManagerType::Pnpm, version, None, source)); } // if yarn.lock or .yarnrc.yml exists, use yarn@latest let yarn_lock_path = workspace_root.path.join("yarn.lock"); let yarnrc_yml_path = workspace_root.path.join(".yarnrc.yml"); if is_exists_file(&yarn_lock_path)? || is_exists_file(&yarnrc_yml_path)? { - return Ok((PackageManagerType::Yarn, version, None)); + return Ok((PackageManagerType::Yarn, version, None, source)); } // if package-lock.json exists, use npm@latest let package_lock_json_path = workspace_root.path.join("package-lock.json"); if is_exists_file(&package_lock_json_path)? { - return Ok((PackageManagerType::Npm, version, None)); + return Ok((PackageManagerType::Npm, version, None, source)); } // if bun.lock (text format) or bun.lockb (binary format) exists, use bun@latest let bun_lock_path = workspace_root.path.join("bun.lock"); if is_exists_file(&bun_lock_path)? { - return Ok((PackageManagerType::Bun, version, None)); + return Ok((PackageManagerType::Bun, version, None, source)); } let bun_lockb_path = workspace_root.path.join("bun.lockb"); if is_exists_file(&bun_lockb_path)? { - return Ok((PackageManagerType::Bun, version, None)); + return Ok((PackageManagerType::Bun, version, None, source)); } // if .pnpmfile.cjs exists, use pnpm@latest let pnpmfile_cjs_path = workspace_root.path.join(".pnpmfile.cjs"); if is_exists_file(&pnpmfile_cjs_path)? { - return Ok((PackageManagerType::Pnpm, version, None)); + return Ok((PackageManagerType::Pnpm, version, None, source)); } // if legacy pnpmfile.cjs exists, use pnpm@latest // https://newreleases.io/project/npm/pnpm/release/6.0.0 let legacy_pnpmfile_cjs_path = workspace_root.path.join("pnpmfile.cjs"); if is_exists_file(&legacy_pnpmfile_cjs_path)? { - return Ok((PackageManagerType::Pnpm, version, None)); + return Ok((PackageManagerType::Pnpm, version, None, source)); } // if bunfig.toml exists, use bun@latest let bunfig_toml_path = workspace_root.path.join("bunfig.toml"); if is_exists_file(&bunfig_toml_path)? { - return Ok((PackageManagerType::Bun, version, None)); + return Ok((PackageManagerType::Bun, version, None, source)); } // if yarn.config.cjs exists, use yarn@latest (yarn 2.0+) let yarn_config_cjs_path = workspace_root.path.join("yarn.config.cjs"); if is_exists_file(&yarn_config_cjs_path)? { - return Ok((PackageManagerType::Yarn, version, None)); + return Ok((PackageManagerType::Yarn, version, None, source)); } // if default is specified, use it if let Some(default) = default { - return Ok((default, version, None)); + return Ok((default, version, None, PackageManagerSource::Default)); } // unrecognized package manager, let user specify the package manager Err(Error::UnrecognizedPackageManager) } -/// Resolve only the explicit `packageManager` field for the current workspace. +/// Resolve the project-declared package manager for the current workspace: +/// the explicit `packageManager` field first, then `devEngines.packageManager` +/// (rfcs/dev-engines.md). /// /// This is intentionally non-mutating: it does not prompt, download a package manager, or write the -/// resolved version back to `package.json`. +/// resolved version back to `package.json`. A `devEngines.packageManager` range resolves +/// against already-downloaded versions when possible; otherwise the raw requirement is +/// kept in `version` and resolved at download time. pub fn resolve_package_manager_from_package_json( cwd: impl AsRef, ) -> Result, Error> { @@ -295,7 +363,37 @@ pub fn resolve_package_manager_from_package_json( Err(vite_workspace::Error::PackageJsonNotFound(_)) => return Ok(None), Err(error) => return Err(error.into()), }; - get_package_manager_from_package_json(&workspace_root) + if let Some(resolution) = get_package_manager_from_package_json(&workspace_root)? { + return Ok(Some(resolution)); + } + + // Fall back to devEngines.packageManager (see rfcs/dev-engines.md) + let Some((package_manager_type, version_req)) = + get_package_manager_from_dev_engines(&workspace_root)? + else { + return Ok(None); + }; + // An absent version means any version satisfies (devEngines spec) + let version_req = version_req.unwrap_or_else(|| "*".into()); + let version = if Version::parse(&version_req).is_ok() { + version_req + } else if let Ok(range) = node_semver::Range::parse(version_req.as_str()) + && let Some(cached) = find_cached_package_manager_version(package_manager_type, &range)? + { + cached + } else { + version_req + }; + + let package_json_path = workspace_root.path.join("package.json"); + Ok(Some(PackageManagerResolution { + package_manager_type, + version, + hash: None, + source: "devEngines.packageManager".into(), + source_path: package_json_path.to_absolute_path_buf(), + project_root: workspace_root.path.to_absolute_path_buf(), + })) } /// Return the managed install directory for a package manager version. @@ -345,6 +443,136 @@ fn get_package_manager_from_package_json( })) } +/// Read the `devEngines.packageManager` field from the workspace root package.json. +fn read_dev_engines_package_manager( + workspace_root: &WorkspaceRoot, +) -> Option { + let package_json_path = workspace_root.path.join("package.json"); + let file = open_exists_file(&package_json_path).ok()??; + // Lenient: a package.json we cannot parse here is reported by other paths + let pkg: vite_shared::PackageJson = serde_json::from_reader(BufReader::new(&file)).ok()?; + pkg.dev_engines.and_then(|dev_engines| dev_engines.package_manager) +} + +/// Resolve the package manager from `devEngines.packageManager` in package.json. +/// +/// Entries are evaluated in order and the first supported entry wins (devEngines +/// spec: "the first acceptable option would be used"). When no entry names a +/// supported package manager, the effective `onFail` of the last entry decides: +/// `ignore`/`warn` fall through to lockfile detection, `error` (the default) and +/// `download` fail with a clear message. See rfcs/dev-engines.md. +/// +/// Returns the package manager type and the raw version requirement +/// (`None` means any version satisfies). +fn get_package_manager_from_dev_engines( + workspace_root: &WorkspaceRoot, +) -> Result)>, Error> { + let Some(field) = read_dev_engines_package_manager(workspace_root) else { + return Ok(None); + }; + let entries = field.entries(); + if entries.is_empty() { + return Ok(None); + } + + for entry in entries { + let Some(package_manager_type) = PackageManagerType::from_name(&entry.name) else { + continue; + }; + // Lenient read: an invalid version range is treated as any version, + // surfaced as a warning here and by `vp env doctor` + let version_req = entry.version.clone().filter(|version| { + let valid = Version::parse(version).is_ok() + || node_semver::Range::parse(version.as_str()).is_ok(); + if !valid { + vite_shared::output::warn(&format!( + "invalid devEngines.packageManager version {version:?} for \ + {package_manager_type}, treating as any version" + )); + } + valid + }); + return Ok(Some((package_manager_type, version_req))); + } + + // No supported entry: the effective onFail of the last entry decides + let names: Str = entries.iter().map(|e| e.name.as_str()).collect::>().join(", ").into(); + match field.effective_on_fail(entries.len() - 1) { + OnFail::Ignore => Ok(None), + OnFail::Warn => { + vite_shared::output::warn(&format!( + "devEngines.packageManager {names:?} is not supported \ + (supported: pnpm, yarn, npm, bun)" + )); + Ok(None) + } + OnFail::Error | OnFail::Download => Err(Error::UnsupportedDevEnginesPackageManager(names)), + } +} + +/// Warn when the explicit `packageManager` field does not satisfy the +/// `devEngines.packageManager` constraint. +/// +/// Per rfcs/dev-engines.md this is a warning for now and becomes a hard error +/// in a future release; npm already errors in this situation. +fn warn_on_dev_engines_package_manager_conflict( + workspace_root: &WorkspaceRoot, + resolution: &PackageManagerResolution, +) { + let Some(field) = read_dev_engines_package_manager(workspace_root) else { + return; + }; + if let Some(message) = dev_engines_package_manager_conflict_message(&field, resolution) { + vite_shared::output::warn(&message); + } +} + +/// Build the conflict message for an explicit `packageManager` field that does +/// not satisfy the `devEngines.packageManager` constraint. +/// +/// Returns `None` when the field is consistent with the constraint (semver-aware: +/// an exact version satisfying a declared range is not a conflict), when the +/// constraint is empty, or when the declared range is not valid semver +/// (`vp env doctor` reports that case). +fn dev_engines_package_manager_conflict_message( + field: &vite_shared::DevEngineField, + resolution: &PackageManagerResolution, +) -> Option { + let entries = field.entries(); + if entries.is_empty() { + return None; + } + + let name = resolution.package_manager_type.to_string(); + let Some(entry) = field.find_by_name(&name) else { + let names = entries.iter().map(|e| e.name.as_str()).collect::>().join(", "); + return Some( + format!( + "packageManager is {name}@{version} but devEngines.packageManager \ + requires {names:?}. This will become an error in a future release.", + version = resolution.version + ) + .into(), + ); + }; + if let Some(required) = &entry.version + && let Ok(range) = node_semver::Range::parse(required.as_str()) + && let Ok(version) = node_semver::Version::parse(resolution.version.as_str()) + && !range.satisfies(&version) + { + return Some( + format!( + "packageManager {name}@{version} does not satisfy \ + devEngines.packageManager {required:?}. This will become an error in a \ + future release.", + version = resolution.version + ) + .into(), + ); + } + None +} + fn parse_package_manager_field( package_manager: &str, package_json_path: &AbsolutePath, @@ -366,13 +594,8 @@ fn parse_package_manager_field( version: version.into(), package_json_path: package_json_path.to_absolute_path_buf(), })?; - let package_manager_type = match name { - "pnpm" => PackageManagerType::Pnpm, - "yarn" => PackageManagerType::Yarn, - "npm" => PackageManagerType::Npm, - "bun" => PackageManagerType::Bun, - _ => return Err(Error::UnsupportedPackageManager(name.into())), - }; + let package_manager_type = PackageManagerType::from_name(name) + .ok_or_else(|| Error::UnsupportedPackageManager(name.into()))?; Ok(Some((package_manager_type, version.into(), hash))) } @@ -396,6 +619,34 @@ fn is_exists_file(path: impl AsRef) -> Result { } } +/// Whether a managed package manager install is complete (usable on the current +/// platform). +/// +/// Always requires the plain `` shim under `/bin/`. On +/// Windows it additionally requires the `.cmd` and `.ps1` wrappers, since those +/// are the files actually invoked there; on other platforms they are never +/// executed, so checking them would only waste two stat calls per cache entry. +/// +/// This is the single source of truth shared by the download fast-path (which +/// skips re-downloading a complete install) and cached-range resolution (which +/// must not select an install the download path would consider incomplete). The +/// two therefore agree on every platform. See rfcs/dev-engines.md. +fn is_package_manager_install_complete( + install_dir: &AbsolutePath, + bin_name: &str, +) -> Result { + let bin_file = install_dir.join("bin").join(bin_name); + if !is_exists_file(&bin_file)? { + return Ok(false); + } + if cfg!(windows) { + Ok(is_exists_file(bin_file.with_extension("cmd"))? + && is_exists_file(bin_file.with_extension("ps1"))?) + } else { + Ok(true) + } +} + async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name @@ -408,6 +659,153 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< Ok(package_json.version) } +/// Abbreviated registry metadata: only the version list is needed. +#[derive(Deserialize)] +struct RegistryPackument { + // a map with ignored values is the idiomatic serde way to read only the keys + #[allow(clippy::zero_sized_map_values)] + #[serde(default)] + versions: HashMap, +} + +/// Fetch all published versions of a package from the npm registry. +/// The npm abbreviated metadata format: only install-relevant fields, much +/// smaller than the full packument (KBs instead of MBs for popular packages). +const NPM_ABBREVIATED_METADATA_ACCEPT: &str = "application/vnd.npm.install-v1+json"; + +async fn fetch_registry_versions(package_name: &str) -> Result, Error> { + let url = get_npm_package_metadata_url(package_name); + let packument: RegistryPackument = + HttpClient::new().get_json_with_accept(&url, NPM_ABBREVIATED_METADATA_ACCEPT).await?; + Ok(packument + .versions + .keys() + .filter_map(|version| node_semver::Version::parse(version).ok()) + .collect()) +} + +/// Whether a version requirement explicitly asks for prereleases. +/// +/// A prerelease marker attaches the hyphen directly to a version +/// (e.g. `^1.0.0-rc`, `>=12.0.0-0`), whereas an npm hyphen range surrounds the +/// hyphen with spaces (`1.0.0 - 2.0.0`) and is a stable range, not a prerelease +/// request. Splitting on whitespace isolates the lone `-` separator (length 1), +/// so only a hyphen embedded in a comparator token counts. +fn requirement_requests_prerelease(version_req: &str) -> bool { + version_req.split_whitespace().any(|token| token.len() > 1 && token.contains('-')) +} + +/// Resolve the latest registry version satisfying `range`. +/// +/// Prereleases are excluded, except when the requirement itself asks for them +/// (a prerelease marker, not an npm hyphen range) and no stable version +/// satisfies the range. +async fn resolve_latest_satisfying_version( + package_manager_type: PackageManagerType, + range: &node_semver::Range, + version_req: &str, +) -> Result { + let package_name = package_manager_type.to_string(); + let mut versions = fetch_registry_versions(&package_name).await?; + // yarn >= 2.0.0 is published as `@yarnpkg/cli-dist`; merge both version lists + if matches!(package_manager_type, PackageManagerType::Yarn) { + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + } + + let best = versions + .iter() + .filter(|version| !version.is_prerelease() && range.satisfies(version)) + .max() + .or_else(|| { + // a range only prereleases can satisfy (e.g. "^12.0.0-0" before a + // stable 12.0.0 exists): allow them when explicitly requested + if requirement_requests_prerelease(version_req) { + versions.iter().filter(|version| range.satisfies(version)).max() + } else { + None + } + }); + + best.map(|version| Str::from(version.to_string())).ok_or_else(|| { + Error::PackageManagerVersionNotFound { + name: package_name.clone().into(), + version: version_req.into(), + url: get_npm_package_metadata_url(&package_name).into(), + } + }) +} + +/// Find the highest already-downloaded package manager version satisfying `range` +/// under `$VP_HOME/package_manager//`. +fn find_cached_package_manager_version( + package_manager_type: PackageManagerType, + range: &node_semver::Range, +) -> Result, Error> { + let home_dir = vite_shared::get_vp_home()?; + let bin_name = package_manager_type.to_string(); + let versions_dir = home_dir.join("package_manager").join(&bin_name); + let entries = match fs::read_dir(&versions_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + + let mut best: Option = None; + for entry in entries.flatten() { + let file_name = entry.file_name(); + let Some(name) = file_name.to_str() else { continue }; + let Ok(version) = node_semver::Version::parse(name) else { continue }; + if !range.satisfies(&version) { + continue; + } + // Skip the filesystem check for versions that cannot beat the current + // best; only a higher candidate is worth stat'ing. + if best.as_ref().is_some_and(|b| version <= *b) { + continue; + } + // Only consider completed installs, using the same completeness check as + // the download fast-path so range resolution never selects a partially + // written install (e.g. plain bin present but `.cmd`/`.ps1` missing). + let install_dir = versions_dir.join(name).join(&bin_name); + if !is_package_manager_install_complete(&install_dir, &bin_name)? { + continue; + } + best = Some(version); + } + Ok(best.map(|version| Str::from(version.to_string()))) +} + +/// Resolve a semver range (e.g. from `devEngines.packageManager`) to an exact +/// version: prefer the highest already-downloaded satisfying version, falling +/// back to the latest satisfying version from the npm registry. +/// See rfcs/dev-engines.md. +async fn resolve_package_manager_range( + package_manager_type: PackageManagerType, + version_req: &str, +) -> Result { + let range = node_semver::Range::parse(version_req).map_err(|_| { + Error::InvalidArgument( + format!( + "invalid {package_manager_type} version {version_req:?}: expected semver \ + 'major.minor.patch' or a semver range" + ) + .into(), + ) + })?; + + if let Some(cached) = find_cached_package_manager_version(package_manager_type, &range)? { + tracing::debug!("Found cached {package_manager_type} {cached} satisfying {version_req}"); + return Ok(cached); + } + + // `*` (any version) resolves to the registry's latest stable + if version_req == "*" { + return get_latest_version(package_manager_type).await; + } + + resolve_latest_satisfying_version(package_manager_type, &range, version_req).await +} + /// Download the package manager and extract it to the vite-plus home directory. /// Return the install directory, e.g. `$VP_HOME/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( @@ -417,8 +815,12 @@ pub async fn download_package_manager( ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let version: Str = if version_or_latest == "latest" { get_latest_version(package_manager_type).await? - } else { + } else if Version::parse(version_or_latest).is_ok() { version_or_latest.into() + } else { + // semver range (e.g. from devEngines.packageManager): prefer an already + // downloaded satisfying version, otherwise resolve from the registry + resolve_package_manager_range(package_manager_type, version_or_latest).await? }; // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. @@ -461,12 +863,7 @@ pub async fn download_package_manager( // If all shims already exist, return the target directory // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) - let bin_prefix = install_dir.join("bin"); - let bin_file = bin_prefix.join(&bin_name); - if is_exists_file(&bin_file)? - && is_exists_file(bin_file.with_extension("cmd"))? - && is_exists_file(bin_file.with_extension("ps1"))? - { + if is_package_manager_install_complete(&install_dir, &bin_name)? { return Ok((install_dir, package_name, version)); } @@ -510,9 +907,11 @@ pub async fn download_package_manager( tracing::debug!("Lock acquired: {:?}", lock_path); // Check again after acquiring the lock, in case another thread completed - // the installation while we were downloading - if is_exists_file(&bin_file)? { - tracing::debug!("bin_file already exists after lock acquisition, skip rename"); + // the installation while we were downloading (same completeness check as the + // fast-path above; create_shim_files below runs under this lock, so post-lock + // the install is all-or-nothing) + if is_package_manager_install_complete(&install_dir, &bin_name)? { + tracing::debug!("install already complete after lock acquisition, skip rename"); return Ok((install_dir, package_name, version)); } @@ -523,7 +922,7 @@ pub async fn download_package_manager( // create shim file tracing::debug!("Create shim files for {}", bin_name); - create_shim_files(package_manager_type, &bin_prefix).await?; + create_shim_files(package_manager_type, &install_dir.join("bin")).await?; Ok((install_dir, package_name, version)) } @@ -576,14 +975,10 @@ async fn download_bun_package_manager( // $VP_HOME/package_manager/bun/{version} let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); let install_dir = target_dir.join("bun"); - let bin_prefix = install_dir.join("bin"); - let bin_file = bin_prefix.join("bun"); - // If shims already exist, return early - if is_exists_file(&bin_file)? - && is_exists_file(bin_file.with_extension("cmd"))? - && is_exists_file(bin_file.with_extension("ps1"))? - { + // If shims already exist, return early (same completeness check as the cache + // and the tgz download path) + if is_package_manager_install_complete(&install_dir, "bun")? { return Ok((install_dir, package_name, version.clone())); } @@ -648,8 +1043,8 @@ async fn download_bun_package_manager( lock_file.lock()?; tracing::debug!("Lock acquired: {:?}", lock_path); - if is_exists_file(&bin_file)? { - tracing::debug!("bun bin_file already exists after lock acquisition, skip rename"); + if is_package_manager_install_complete(&install_dir, "bun")? { + tracing::debug!("bun install already complete after lock acquisition, skip rename"); return Ok((install_dir, package_name, version.clone())); } @@ -660,7 +1055,7 @@ async fn download_bun_package_manager( // Create native binary shims tracing::debug!("Create shim files for bun"); - create_shim_files(PackageManagerType::Bun, &bin_prefix).await?; + create_shim_files(PackageManagerType::Bun, &install_dir.join("bin")).await?; Ok((install_dir, package_name, version.clone())) } @@ -769,29 +1164,62 @@ async fn create_bun_shim_files(bin_prefix: &AbsolutePath) -> Result<(), Error> { Ok(()) } -async fn set_package_manager_field( +/// Write the resolved package manager into `devEngines.packageManager`. +/// +/// Used by auto-pin when detection had no explicit field (rfcs/dev-engines.md): +/// the exact resolved version is recorded with `onFail: "download"` so future +/// runs are deterministic. Preserves the file's key order and formatting style, +/// placing `devEngines` next to `engines` when present. +async fn set_dev_engines_package_manager_field( package_json_path: impl AsRef, package_manager_type: PackageManagerType, version: &str, ) -> Result<(), Error> { let package_json_path = package_json_path.as_ref(); - let package_manager_value = format!("{package_manager_type}@{version}"); - let mut package_json = if is_exists_file(package_json_path)? { - let content = tokio::fs::read(&package_json_path).await?; - serde_json::from_slice(&content)? + let content = if is_exists_file(package_json_path)? { + tokio::fs::read_to_string(&package_json_path).await? } else { - serde_json::json!({}) + "{}\n".to_string() }; - // use IndexMap to preserve the order of the fields - if let Some(package_json) = package_json.as_object_mut() { - package_json.insert("packageManager".into(), serde_json::json!(package_manager_value)); - } - let json_string = serde_json::to_string_pretty(&package_json)?; - tokio::fs::write(&package_json_path, json_string).await?; + let entry = vite_shared::dev_engine_entry(&package_manager_type.to_string(), version); + let updated = vite_shared::edit_json_object(&content, |obj| { + let Some(dev_engines) = obj.get_mut("devEngines").and_then(|v| v.as_object_mut()) else { + vite_shared::insert_after( + obj, + "engines", + "devEngines", + serde_json::json!({ "packageManager": entry }), + ); + return; + }; + // Auto-pin only fires when detection found no usable entry, but the field + // may still declare entries Vite+ does not act on (e.g. other package + // managers with onFail: ignore). Those are preserved, never replaced. + match dev_engines.get_mut("packageManager") { + // existing single entry: convert to array form, keeping it first + Some(existing @ serde_json::Value::Object(_)) => { + let existing = std::mem::take(existing); + dev_engines.insert( + "packageManager".into(), + serde_json::Value::Array(vec![existing, entry]), + ); + } + // existing array: append the resolved entry + Some(serde_json::Value::Array(entries)) => { + entries.push(entry); + } + // absent or malformed (spec-invalid) value: write a single entry + _ => { + dev_engines.insert("packageManager".into(), entry); + } + } + })?; + tokio::fs::write(&package_json_path, updated).await?; tracing::debug!( - "set_package_manager_field: {:?} to {:?}", + "set_dev_engines_package_manager_field: {:?} to {}@{}", package_json_path, - package_manager_value + package_manager_type, + version ); Ok(()) } @@ -1038,6 +1466,7 @@ mod tests { use std::fs; use tempfile::{TempDir, tempdir}; + use vite_shared::EnvConfig; use super::*; @@ -1054,6 +1483,21 @@ mod tests { .expect("Failed to write pnpm-workspace.yaml"); } + #[test] + fn test_requirement_requests_prerelease() { + // prerelease markers attached to a version + assert!(requirement_requests_prerelease("^1.0.0-rc")); + assert!(requirement_requests_prerelease(">=12.0.0-0")); + assert!(requirement_requests_prerelease(">1.0.0-alpha <2.0.0")); + // stable ranges, including the npm hyphen range whose ` - ` separator + // must NOT be read as a prerelease request + assert!(!requirement_requests_prerelease("1.0.0 - 2.0.0")); + assert!(!requirement_requests_prerelease("^11.0.0")); + assert!(!requirement_requests_prerelease(">=10 <12")); + assert!(!requirement_requests_prerelease("*")); + assert!(!requirement_requests_prerelease("11.5.1")); + } + #[test] fn test_package_manager_type_from_tool_includes_aliases() { assert_eq!(PackageManagerType::from_tool("npm"), Some(PackageManagerType::Npm)); @@ -1068,6 +1512,118 @@ mod tests { assert_eq!(PackageManagerType::from_tool("tsc"), None); } + /// How fully a fake package manager install is written. + enum InstallState { + /// No shim files at all (`bin/` exists but is empty). + NoBin, + /// Plain bin only, no `.cmd`/`.ps1` (a download interrupted mid-write). + BinOnly, + /// Plain bin plus the `.cmd` and `.ps1` wrappers. + Complete, + } + + /// Create a fake managed package manager install under + /// `/package_manager////bin/`. + fn write_pm_install(vp_home: &AbsolutePath, name: &str, version: &str, state: InstallState) { + let bin_dir = + vp_home.join("package_manager").join(name).join(version).join(name).join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let bin_file = bin_dir.join(name); + if matches!(state, InstallState::BinOnly | InstallState::Complete) { + fs::write(&bin_file, "shim").unwrap(); + } + if matches!(state, InstallState::Complete) { + fs::write(bin_file.with_extension("cmd"), "shim").unwrap(); + fs::write(bin_file.with_extension("ps1"), "shim").unwrap(); + } + } + + fn find_cached_pnpm(vp_home: &AbsolutePath) -> Option { + let range = node_semver::Range::parse("^11.0.0").unwrap(); + EnvConfig::test_scope(EnvConfig::for_test_with_home(vp_home.as_path()), || { + find_cached_package_manager_version(PackageManagerType::Pnpm, &range) + }) + .unwrap() + } + + #[test] + fn test_find_cached_package_manager_version_skips_install_without_bin() { + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // 11.6.0 has no bin shim at all: incomplete on every platform, so the + // complete 11.5.1 wins even though 11.6.0 is higher and satisfies the range + write_pm_install(&vp_home, "pnpm", "11.5.1", InstallState::Complete); + write_pm_install(&vp_home, "pnpm", "11.6.0", InstallState::NoBin); + + assert_eq!(find_cached_pnpm(&vp_home).as_deref(), Some("11.5.1")); + } + + #[test] + fn test_find_cached_package_manager_version_none_when_no_complete_install() { + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + write_pm_install(&vp_home, "pnpm", "11.6.0", InstallState::NoBin); + + // nothing usable is cached; resolution falls through to the registry + assert_eq!(find_cached_pnpm(&vp_home), None); + } + + #[cfg(windows)] + #[test] + fn test_find_cached_package_manager_version_skips_missing_windows_shims() { + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // On Windows the `.cmd`/`.ps1` wrappers are the files actually invoked, so + // a bin-only 11.6.0 is incomplete and the complete 11.5.1 wins + write_pm_install(&vp_home, "pnpm", "11.5.1", InstallState::Complete); + write_pm_install(&vp_home, "pnpm", "11.6.0", InstallState::BinOnly); + + assert_eq!(find_cached_pnpm(&vp_home).as_deref(), Some("11.5.1")); + } + + #[cfg(not(windows))] + #[test] + fn test_find_cached_package_manager_version_accepts_bin_only_off_windows() { + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Off Windows only the plain bin is invoked, so a bin-only 11.6.0 is a + // usable install and the highest satisfying version wins + write_pm_install(&vp_home, "pnpm", "11.5.1", InstallState::Complete); + write_pm_install(&vp_home, "pnpm", "11.6.0", InstallState::BinOnly); + + assert_eq!(find_cached_pnpm(&vp_home).as_deref(), Some("11.6.0")); + } + + #[test] + fn test_is_package_manager_install_complete() { + let temp_dir = create_temp_dir(); + let install_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let bin_dir = install_dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let bin_file = bin_dir.join("pnpm"); + + // missing plain bin: incomplete on every platform + assert!(!is_package_manager_install_complete(&install_dir, "pnpm").unwrap()); + + fs::write(&bin_file, "shim").unwrap(); + if cfg!(windows) { + // the Windows wrappers are still required + assert!(!is_package_manager_install_complete(&install_dir, "pnpm").unwrap()); + fs::write(bin_file.with_extension("cmd"), "shim").unwrap(); + // .cmd present but .ps1 missing + assert!(!is_package_manager_install_complete(&install_dir, "pnpm").unwrap()); + fs::write(bin_file.with_extension("ps1"), "shim").unwrap(); + assert!(is_package_manager_install_complete(&install_dir, "pnpm").unwrap()); + } else { + // the plain bin is the only file invoked off Windows + assert!(is_package_manager_install_complete(&install_dir, "pnpm").unwrap()); + } + } + #[test] fn test_bin_name_for_tool_preserves_aliases() { assert_eq!(PackageManagerType::Npm.bin_name_for_tool("npm"), "npm"); @@ -1443,12 +1999,17 @@ mod tests { PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); assert_eq!(result.bin_name, "pnpm"); - // check if the package.json file has the `packageManager` field + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir.path().join("package.json"); let package_json: serde_json::Value = serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); println!("package_json: {package_json:?}"); - assert!(package_json["packageManager"].as_str().unwrap().starts_with("pnpm@")); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); + assert!(Version::parse(entry["version"].as_str().unwrap()).is_ok()); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // the legacy field is not written + assert!(package_json.get("packageManager").is_none()); // keep other fields assert_eq!(package_json["version"].as_str().unwrap(), "1.0.0"); assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); @@ -1476,12 +2037,14 @@ mod tests { "bin_prefix should end with yarn/bin, but got {:?}", result.get_bin_prefix() ); - // package.json should have the `packageManager` field + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir_path.join("package.json"); let package_json: serde_json::Value = serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); println!("package_json: {package_json:?}"); - assert!(package_json["packageManager"].as_str().unwrap().starts_with("yarn@")); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); // keep other fields assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } @@ -1583,7 +2146,7 @@ mod tests { create_package_json(&temp_dir_path, package_content); let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).unwrap(); assert_eq!(pm_type, PackageManagerType::Yarn); @@ -1595,6 +2158,550 @@ mod tests { ); } + #[tokio::test] + async fn test_resolve_package_manager_from_dev_engines_exact() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": {"packageManager": {"name": "pnpm", "version": "9.15.0"}} + }"#; + create_package_json(&temp_dir_path, package_content); + + let resolution = + resolve_package_manager_from_package_json(&temp_dir_path).unwrap().unwrap(); + assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); + assert_eq!(resolution.version, "9.15.0"); + assert!(resolution.hash.is_none()); + assert_eq!(resolution.source, "devEngines.packageManager"); + } + + #[tokio::test] + async fn test_resolve_package_manager_from_dev_engines_uncached_range_kept() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + // a range no downloaded version can satisfy: the raw requirement is kept + // and resolved at download time + let package_content = r#"{ + "name": "test-package", + "devEngines": {"packageManager": {"name": "pnpm", "version": ">=999.0.0"}} + }"#; + create_package_json(&temp_dir_path, package_content); + + let resolution = + resolve_package_manager_from_package_json(&temp_dir_path).unwrap().unwrap(); + assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); + assert_eq!(resolution.version, ">=999.0.0"); + assert_eq!(resolution.source, "devEngines.packageManager"); + } + + #[tokio::test] + async fn test_resolve_package_manager_field_wins_over_dev_engines() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "packageManager": "npm@10.5.0", + "devEngines": {"packageManager": {"name": "pnpm", "version": "^9.0.0"}} + }"#; + create_package_json(&temp_dir_path, package_content); + + let resolution = + resolve_package_manager_from_package_json(&temp_dir_path).unwrap().unwrap(); + assert_eq!(resolution.package_manager_type, PackageManagerType::Npm); + assert_eq!(resolution.version, "10.5.0"); + assert_eq!(resolution.source, "packageManager"); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_exact_version() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": {"name": "pnpm", "version": "9.15.0", "onFail": "download"} + } + }"#; + create_package_json(&temp_dir_path, package_content); + // a lockfile that would otherwise win: devEngines has higher priority + fs::write(temp_dir_path.join("package-lock.json"), "{}").unwrap(); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, hash, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + assert_eq!(pm_type, PackageManagerType::Pnpm); + assert_eq!(version, "9.15.0"); + assert!(hash.is_none()); + assert_eq!(source, PackageManagerSource::DevEnginesPackageManager); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_range_preserved() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": {"name": "pnpm", "version": "^9.0.0"} + } + }"#; + create_package_json(&temp_dir_path, package_content); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + assert_eq!(pm_type, PackageManagerType::Pnpm); + // the range is preserved here; download resolves it to an exact version + assert_eq!(version, "^9.0.0"); + assert_eq!(source, PackageManagerSource::DevEnginesPackageManager); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_absent_version_is_any() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": {"packageManager": {"name": "bun"}} + }"#; + create_package_json(&temp_dir_path, package_content); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + assert_eq!(pm_type, PackageManagerType::Bun); + assert_eq!(version, "*"); + assert_eq!(source, PackageManagerSource::DevEnginesPackageManager); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_invalid_version_is_any() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": {"packageManager": {"name": "pnpm", "version": "not-a-version"}} + }"#; + create_package_json(&temp_dir_path, package_content); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + assert_eq!(pm_type, PackageManagerType::Pnpm); + // lenient read: an invalid range is treated as any version + assert_eq!(version, "*"); + assert_eq!(source, PackageManagerSource::DevEnginesPackageManager); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_array_first_supported_wins() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": [ + {"name": "vlt", "version": "^1.0.0"}, + {"name": "yarn", "version": "4.9.2"}, + {"name": "npm", "version": ">=10"} + ] + } + }"#; + create_package_json(&temp_dir_path, package_content); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + assert_eq!(pm_type, PackageManagerType::Yarn); + assert_eq!(version, "4.9.2"); + assert_eq!(source, PackageManagerSource::DevEnginesPackageManager); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_unsupported_single_errors() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + // single entry defaults to onFail: error (devEngines spec) + let package_content = r#"{ + "name": "test-package", + "devEngines": {"packageManager": {"name": "vlt", "version": "^1.0.0"}} + }"#; + create_package_json(&temp_dir_path, package_content); + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'").unwrap(); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let result = get_package_manager_type_and_version(&workspace_root, None); + assert!(matches!(result, Err(Error::UnsupportedDevEnginesPackageManager(_)))); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_unsupported_ignore_falls_through() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": {"name": "vlt", "version": "^1.0.0", "onFail": "ignore"} + } + }"#; + create_package_json(&temp_dir_path, package_content); + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'").unwrap(); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + // onFail: ignore continues down the detection chain to the lockfile + assert_eq!(pm_type, PackageManagerType::Pnpm); + assert_eq!(version, "latest"); + assert_eq!(source, PackageManagerSource::LockfileOrConfig); + } + + // npm-install-checks: "noop options" / "empty array along side error" + #[tokio::test] + async fn test_detect_dev_engines_package_manager_empty_array_falls_through() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": {"packageManager": []} + }"#; + create_package_json(&temp_dir_path, package_content); + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'").unwrap(); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + // an empty array imposes nothing: detection falls through to the lockfile + assert_eq!(pm_type, PackageManagerType::Pnpm); + assert_eq!(version, "latest"); + assert_eq!(source, PackageManagerSource::LockfileOrConfig); + } + + // npm-install-checks: "returns the last failure" (array with no acceptable + // entry applies the effective onFail of the last entry, which defaults to error) + #[tokio::test] + async fn test_detect_dev_engines_package_manager_all_unsupported_array_errors() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": [ + {"name": "vlt", "version": "^1.0.0"}, + {"name": "deno", "version": "^2.0.0"} + ] + } + }"#; + create_package_json(&temp_dir_path, package_content); + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'").unwrap(); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let result = get_package_manager_type_and_version(&workspace_root, None); + assert!(matches!(result, Err(Error::UnsupportedDevEnginesPackageManager(_)))); + } + + #[tokio::test] + async fn test_detect_dev_engines_package_manager_array_last_warn_falls_through() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": [ + {"name": "vlt", "version": "^1.0.0", "onFail": "ignore"}, + {"name": "deno", "version": "^2.0.0", "onFail": "warn"} + ] + } + }"#; + create_package_json(&temp_dir_path, package_content); + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'").unwrap(); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + // onFail: warn on the last entry warns and continues down the chain + assert_eq!(pm_type, PackageManagerType::Pnpm); + assert_eq!(version, "latest"); + assert_eq!(source, PackageManagerSource::LockfileOrConfig); + } + + // npm-install-checks: "spec 2" uses [bun, yarn] where npm matches the current + // environment. Vite+ provisions the environment instead of validating it, so + // the first supported entry wins (rfcs/dev-engines.md). + #[tokio::test] + async fn test_detect_dev_engines_package_manager_first_supported_entry_wins() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": [ + {"name": "bun", "version": ">= 1.0.0", "onFail": "ignore"}, + {"name": "yarn", "version": "3.2.3", "onFail": "download"} + ] + } + }"#; + create_package_json(&temp_dir_path, package_content); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + assert_eq!(pm_type, PackageManagerType::Bun); + assert_eq!(version, ">= 1.0.0"); + assert_eq!(source, PackageManagerSource::DevEnginesPackageManager); + } + + /// Test helper: parse a `devEngines.packageManager` field from JSON. + fn parse_dev_engines_pm_field(json: &str) -> vite_shared::DevEngineField { + let pkg: vite_shared::PackageJson = serde_json::from_str(json).unwrap(); + pkg.dev_engines.unwrap().package_manager.unwrap() + } + + /// Test helper: build a `PackageManagerResolution` for conflict-message tests. + fn resolution_for_conflict_test( + package_manager_type: PackageManagerType, + version: &str, + ) -> PackageManagerResolution { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + PackageManagerResolution { + package_manager_type, + version: version.into(), + hash: None, + source: "packageManager".into(), + source_path: temp_dir_path.join("package.json"), + project_root: temp_dir_path, + } + } + + // npm-install-checks: "invalid name" + #[test] + fn test_dev_engines_conflict_message_name_mismatch() { + let field = parse_dev_engines_pm_field( + r#"{"devEngines": {"packageManager": {"name": "pnpm", "version": "^11.0.0"}}}"#, + ); + let resolution = resolution_for_conflict_test(PackageManagerType::Npm, "10.5.0"); + + let message = dev_engines_package_manager_conflict_message(&field, &resolution).unwrap(); + assert!(message.contains("packageManager is npm@10.5.0"), "got: {message}"); + assert!(message.contains("requires \"pnpm\""), "got: {message}"); + assert!(message.contains("error in a future release"), "got: {message}"); + } + + // npm-install-checks: "semver version is not in range" + #[test] + fn test_dev_engines_conflict_message_version_not_satisfying() { + let field = parse_dev_engines_pm_field( + r#"{"devEngines": {"packageManager": {"name": "pnpm", "version": "^11.0.0"}}}"#, + ); + let resolution = resolution_for_conflict_test(PackageManagerType::Pnpm, "10.9.0"); + + let message = dev_engines_package_manager_conflict_message(&field, &resolution).unwrap(); + assert!( + message.contains("pnpm@10.9.0 does not satisfy devEngines.packageManager"), + "got: {message}" + ); + assert!(message.contains("error in a future release"), "got: {message}"); + } + + // npm-install-checks: "semver version is in range" / "name only" / + // non-semver wanted versions (doctor reports those, no conflict warning here) + #[test] + fn test_dev_engines_conflict_message_none_when_consistent() { + let resolution = resolution_for_conflict_test(PackageManagerType::Pnpm, "11.5.1"); + + // exact version satisfying the declared range is not a conflict + let field = parse_dev_engines_pm_field( + r#"{"devEngines": {"packageManager": {"name": "pnpm", "version": "^11.0.0"}}}"#, + ); + assert!(dev_engines_package_manager_conflict_message(&field, &resolution).is_none()); + + // name only: any version satisfies + let field = + parse_dev_engines_pm_field(r#"{"devEngines": {"packageManager": {"name": "pnpm"}}}"#); + assert!(dev_engines_package_manager_conflict_message(&field, &resolution).is_none()); + + // a non-semver wanted version is not range-checked here + let field = parse_dev_engines_pm_field( + r#"{"devEngines": {"packageManager": {"name": "pnpm", "version": "test-version"}}}"#, + ); + assert!(dev_engines_package_manager_conflict_message(&field, &resolution).is_none()); + + // empty array imposes nothing + let field = parse_dev_engines_pm_field(r#"{"devEngines": {"packageManager": []}}"#); + assert!(dev_engines_package_manager_conflict_message(&field, &resolution).is_none()); + } + + #[tokio::test] + async fn test_detect_package_manager_field_priority_over_dev_engines() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{ + "name": "test-package", + "packageManager": "npm@10.5.0", + "devEngines": { + "packageManager": {"name": "pnpm", "version": "^9.0.0"} + } + }"#; + create_package_json(&temp_dir_path, package_content); + + let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); + let (pm_type, version, _, source) = + get_package_manager_type_and_version(&workspace_root, None).unwrap(); + + // packageManager field drives selection; a conflict warning is printed + assert_eq!(pm_type, PackageManagerType::Npm); + assert_eq!(version, "10.5.0"); + assert_eq!(source, PackageManagerSource::PackageManagerField); + } + + #[tokio::test] + async fn test_set_dev_engines_package_manager_field_preserves_format_and_engines() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_dir_path.join("package.json"); + // 4-space indentation and an existing engines.node that must stay unchanged + let package_content = "{\n \"name\": \"test-package\",\n \"engines\": {\n \"node\": \">=20.0.0\"\n },\n \"scripts\": {}\n}\n"; + fs::write(&package_json_path, package_content).unwrap(); + + set_dev_engines_package_manager_field( + &package_json_path, + PackageManagerType::Pnpm, + "9.15.0", + ) + .await + .unwrap(); + + let updated = fs::read_to_string(&package_json_path).unwrap(); + // engines.node is kept unchanged and devEngines is placed right after it + assert_eq!( + updated, + "{\n \"name\": \"test-package\",\n \"engines\": {\n \"node\": \">=20.0.0\"\n },\n \"devEngines\": {\n \"packageManager\": {\n \"name\": \"pnpm\",\n \"version\": \"9.15.0\",\n \"onFail\": \"download\"\n }\n },\n \"scripts\": {}\n}\n" + ); + } + + #[tokio::test] + async fn test_set_dev_engines_package_manager_field_appends_to_existing_array() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_dir_path.join("package.json"); + // entries Vite+ does not act on (detection fell through to a lockfile) + // must be preserved, never replaced + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "packageManager": [ + { + "name": "vlt", + "version": "^1.0.0", + "onFail": "ignore" + } + ] + } +} +"#; + fs::write(&package_json_path, package_content).unwrap(); + + set_dev_engines_package_manager_field( + &package_json_path, + PackageManagerType::Pnpm, + "9.15.0", + ) + .await + .unwrap(); + + let updated = fs::read_to_string(&package_json_path).unwrap(); + let package_json: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let entries = package_json["devEngines"]["packageManager"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + // the existing entry stays first with its onFail intact + assert_eq!(entries[0]["name"].as_str().unwrap(), "vlt"); + assert_eq!(entries[0]["onFail"].as_str().unwrap(), "ignore"); + assert_eq!(entries[1]["name"].as_str().unwrap(), "pnpm"); + assert_eq!(entries[1]["version"].as_str().unwrap(), "9.15.0"); + assert_eq!(entries[1]["onFail"].as_str().unwrap(), "download"); + } + + #[tokio::test] + async fn test_set_dev_engines_package_manager_field_converts_single_entry_to_array() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_dir_path.join("package.json"); + let package_content = r#"{ + "devEngines": { + "packageManager": { + "name": "vlt", + "version": "^1.0.0", + "onFail": "warn" + } + } +} +"#; + fs::write(&package_json_path, package_content).unwrap(); + + set_dev_engines_package_manager_field( + &package_json_path, + PackageManagerType::Npm, + "11.4.0", + ) + .await + .unwrap(); + + let updated = fs::read_to_string(&package_json_path).unwrap(); + let package_json: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let entries = package_json["devEngines"]["packageManager"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["name"].as_str().unwrap(), "vlt"); + assert_eq!(entries[0]["onFail"].as_str().unwrap(), "warn"); + assert_eq!(entries[1]["name"].as_str().unwrap(), "npm"); + assert_eq!(entries[1]["version"].as_str().unwrap(), "11.4.0"); + } + + #[tokio::test] + async fn test_set_dev_engines_package_manager_field_keeps_existing_runtime() { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_dir_path.join("package.json"); + let package_content = r#"{ + "name": "test-package", + "devEngines": { + "runtime": { + "name": "node", + "version": "^24.0.0" + } + } +} +"#; + fs::write(&package_json_path, package_content).unwrap(); + + set_dev_engines_package_manager_field( + &package_json_path, + PackageManagerType::Npm, + "11.4.0", + ) + .await + .unwrap(); + + let updated = fs::read_to_string(&package_json_path).unwrap(); + let package_json: serde_json::Value = serde_json::from_str(&updated).unwrap(); + // the existing runtime entry is preserved + assert_eq!(package_json["devEngines"]["runtime"]["version"].as_str().unwrap(), "^24.0.0"); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "npm"); + assert_eq!(entry["version"].as_str().unwrap(), "11.4.0"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + } + #[tokio::test] async fn test_parse_package_manager_with_sha1_hash() { let temp_dir = create_temp_dir(); @@ -1605,7 +2712,7 @@ mod tests { create_package_json(&temp_dir_path, package_content); let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).unwrap(); assert_eq!(pm_type, PackageManagerType::Npm); @@ -1624,7 +2731,7 @@ mod tests { create_package_json(&temp_dir_path, package_content); let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).unwrap(); assert_eq!(pm_type, PackageManagerType::Pnpm); @@ -1646,7 +2753,7 @@ mod tests { create_package_json(&temp_dir_path, package_content); let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).unwrap(); assert_eq!(pm_type, PackageManagerType::Yarn); @@ -1668,7 +2775,7 @@ mod tests { create_package_json(&temp_dir_path, package_content); let (workspace_root, _) = find_workspace_root(&temp_dir_path).unwrap(); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).unwrap(); assert_eq!(pm_type, PackageManagerType::Pnpm); @@ -1895,12 +3002,13 @@ mod tests { .await .expect("Should use default"); assert_eq!(result.bin_name, "yarn"); - // package.json should have the `packageManager` field + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir_path.join("package.json"); let package_json: serde_json::Value = serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - // println!("package_json: {:?}", package_json); - assert!(package_json["packageManager"].as_str().unwrap().starts_with("yarn@")); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); // keep other fields assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } @@ -2059,11 +3167,13 @@ mod tests { "bin_prefix should end with yarn/bin, but got {:?}", result.get_bin_prefix() ); - // package.json should have the `packageManager` field + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir.path().join("package.json"); let package_json: serde_json::Value = serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - assert!(package_json["packageManager"].as_str().unwrap().starts_with("yarn@")); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); // keep other fields assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } @@ -2090,11 +3200,13 @@ mod tests { "bin_prefix should end with pnpm/bin, but got {:?}", result.get_bin_prefix() ); - // package.json should have the `packageManager` field + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir_path.join("package.json"); let package_json: serde_json::Value = serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - assert!(package_json["packageManager"].as_str().unwrap().starts_with("pnpm@")); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); // keep other fields assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } @@ -2124,11 +3236,13 @@ mod tests { "bin_prefix should end with yarn/bin, but got {:?}", result.get_bin_prefix() ); - // package.json should have the `packageManager` field + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) let package_json_path = temp_dir_path.join("package.json"); let package_json: serde_json::Value = serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - assert!(package_json["packageManager"].as_str().unwrap().starts_with("yarn@")); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); // keep other fields assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } @@ -2209,7 +3323,7 @@ mod tests { let (workspace_root, _) = find_workspace_root(&temp_dir_path).expect("Should find workspace root"); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).expect("Should detect bun"); assert_eq!(pm_type, PackageManagerType::Bun); assert_eq!(version.as_str(), "latest"); @@ -2229,7 +3343,7 @@ mod tests { let (workspace_root, _) = find_workspace_root(&temp_dir_path).expect("Should find workspace root"); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).expect("Should detect bun"); assert_eq!(pm_type, PackageManagerType::Bun); assert_eq!(version.as_str(), "latest"); @@ -2249,7 +3363,7 @@ mod tests { let (workspace_root, _) = find_workspace_root(&temp_dir_path).expect("Should find workspace root"); - let (pm_type, version, hash) = + let (pm_type, version, hash, _) = get_package_manager_type_and_version(&workspace_root, None).expect("Should detect bun"); assert_eq!(pm_type, PackageManagerType::Bun); assert_eq!(version.as_str(), "latest"); @@ -2265,8 +3379,9 @@ mod tests { let (workspace_root, _) = find_workspace_root(&temp_dir_path).expect("Should find workspace root"); - let (pm_type, version, hash) = get_package_manager_type_and_version(&workspace_root, None) - .expect("Should detect bun from packageManager field"); + let (pm_type, version, hash, _) = + get_package_manager_type_and_version(&workspace_root, None) + .expect("Should detect bun from packageManager field"); assert_eq!(pm_type, PackageManagerType::Bun); assert_eq!(version.as_str(), "1.2.0"); assert!(hash.is_none()); @@ -2282,8 +3397,9 @@ mod tests { let (workspace_root, _) = find_workspace_root(&temp_dir_path).expect("Should find workspace root"); - let (pm_type, version, hash) = get_package_manager_type_and_version(&workspace_root, None) - .expect("Should detect bun with hash"); + let (pm_type, version, hash, _) = + get_package_manager_type_and_version(&workspace_root, None) + .expect("Should detect bun with hash"); assert_eq!(pm_type, PackageManagerType::Bun); assert_eq!(version.as_str(), "1.2.0"); assert_eq!(hash.unwrap().as_str(), "sha512.abc123"); @@ -2304,7 +3420,7 @@ mod tests { let (workspace_root, _) = find_workspace_root(&temp_dir_path).expect("Should find workspace root"); - let (pm_type, _, _) = + let (pm_type, _, _, _) = get_package_manager_type_and_version(&workspace_root, None).expect("Should detect bun"); assert_eq!( pm_type, diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index e60e55961f..79e1cda18e 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -59,16 +59,26 @@ impl HttpClient { } async fn get(&self, url: &str) -> Result { + self.get_with_accept(url, None).await + } + + async fn get_with_accept(&self, url: &str, accept: Option<&str>) -> Result { let client = vite_shared::shared_http_client(); - let response = (|| async { client.get(url).send().await?.error_for_status() }) - .retry( - ExponentialBuilder::default() - .with_jitter() - .with_min_delay(Duration::from_millis(self.min_delay)) - .with_max_times(self.max_times), - ) - .await?; + let response = (|| async { + let mut request = client.get(url); + if let Some(accept) = accept { + request = request.header(reqwest::header::ACCEPT, accept); + } + request.send().await?.error_for_status() + }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(self.min_delay)) + .with_max_times(self.max_times), + ) + .await?; Ok(response) } @@ -91,6 +101,31 @@ impl HttpClient { Ok(data) } + /// Get JSON data from a URL with a custom Accept header + /// (e.g. the npm abbreviated metadata format, which is much smaller than the + /// full packument) + /// + /// # Arguments + /// + /// * `url` - The URL to fetch JSON from + /// * `accept` - The Accept header value + /// + /// # Returns + /// + /// * `Ok(T)` - Deserialized JSON data + /// * `Err(e)` - If the request fails or JSON deserialization fails + pub async fn get_json_with_accept( + &self, + url: &str, + accept: &str, + ) -> Result { + tracing::debug!("Fetching JSON from: {} (accept: {})", url, accept); + + let response = self.get_with_accept(url, Some(accept)).await?; + let data = response.json::().await?; + Ok(data) + } + /// Download a file to a specified path /// /// # Arguments diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 53043c3b01..4d6125a9a5 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -248,10 +248,10 @@ fn is_retryable_runtime_error(err: &Error) -> bool { 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) + /// Version from `devEngines.runtime` in package.json DevEnginesRuntime, + /// Version from `engines.node` in package.json (lowest priority) + EnginesNode, } impl std::fmt::Display for VersionSource { @@ -279,10 +279,12 @@ pub struct VersionResolution { /// Resolve Node.js version from project configuration. /// -/// At each directory level, searches for version in the following priority order: +/// At each directory level, searches for version in the following priority order +/// (see rfcs/dev-engines.md: `devEngines.runtime` is the development-environment +/// requirement, while `engines.node` is a consumer-facing support range): /// 1. `.node-version` file -/// 2. `package.json#engines.node` -/// 3. `package.json#devEngines.runtime[name="node"]` +/// 2. `package.json#devEngines.runtime[name="node"]` +/// 3. `package.json#engines.node` /// /// If `walk_up` is true, walks up the directory tree checking each level until /// a version is found or the root is reached. @@ -317,33 +319,33 @@ pub async fn resolve_node_version( })); } - // 2-3. Check package.json (engines.node and devEngines.runtime) + // 2-3. Check package.json (devEngines.runtime and engines.node) let package_json_path = current.join("package.json"); if tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { let content = tokio::fs::read_to_string(&package_json_path).await?; if let Ok(pkg) = serde_json::from_str::(&content) { - // Check engines.node first - if let Some(engines) = &pkg.engines - && let Some(node) = &engines.node - && !node.is_empty() + // Check devEngines.runtime first (dev-environment requirement) + if let Some(dev_engines) = &pkg.dev_engines + && let Some(runtime) = &dev_engines.runtime + && let Some(node_rt) = runtime.find_by_name("node") + && let Some(version) = &node_rt.version { return Ok(Some(VersionResolution { - version: node.clone(), - source: VersionSource::EnginesNode, + version: version.clone(), + source: VersionSource::DevEnginesRuntime, source_path: Some(package_json_path), project_root: Some(current.to_absolute_path_buf()), })); } - // Check devEngines.runtime - if let Some(dev_engines) = &pkg.dev_engines - && let Some(runtime) = &dev_engines.runtime - && let Some(node_rt) = runtime.find_by_name("node") - && !node_rt.version.is_empty() + // Check engines.node (consumer-facing support range) + if let Some(engines) = &pkg.engines + && let Some(node) = &engines.node + && !node.is_empty() { return Ok(Some(VersionResolution { - version: node_rt.version.clone(), - source: VersionSource::DevEnginesRuntime, + version: node.clone(), + source: VersionSource::EnginesNode, source_path: Some(package_json_path), project_root: Some(current.to_absolute_path_buf()), })); @@ -370,8 +372,8 @@ pub async fn resolve_node_version( /// /// 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) +/// 2. `devEngines.runtime` in package.json +/// 3. `engines.node` in package.json (lowest) /// /// If no version source is found, uses the latest installed version from cache, /// or falls back to the latest LTS version from the network. @@ -415,21 +417,18 @@ pub async fn download_runtime_for_project( 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(|p| p.dev_engines_runtime("node")) + .and_then(|r| r.version.clone()) .and_then(|v| normalize_version(&v, "devEngines.runtime")); // Determine the actual version requirement to use let (version_req, source) = if let Some(ref v) = version_req { (v.clone(), resolution.as_ref().map(|r| r.source)) - } else if let Some(ref v) = engines_node { - // Fall through if primary source was invalid - (v.clone(), Some(VersionSource::EnginesNode)) } else if let Some(ref v) = dev_engines_runtime { + // Fall through if primary source was invalid (v.clone(), Some(VersionSource::DevEnginesRuntime)) + } else if let Some(ref v) = engines_node { + (v.clone(), Some(VersionSource::EnginesNode)) } else { (Str::default(), None) }; @@ -1047,7 +1046,7 @@ mod tests { } #[tokio::test] - async fn test_engines_node_takes_priority_over_dev_engines() { + async fn test_dev_engines_takes_priority_over_engines_node() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -1059,10 +1058,11 @@ mod tests { tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); let runtime = download_runtime_for_project(&temp_path).await.unwrap().0; - // Should use engines.node (^20.18.0), which will resolve to a 20.x version + // devEngines.runtime is the dev-environment requirement and wins over the + // consumer-facing engines.node range (rfcs/dev-engines.md) let version = runtime.version(); let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); + assert_eq!(parsed.major, 22); } #[tokio::test] @@ -1505,6 +1505,65 @@ mod tests { assert_eq!(resolution.source, VersionSource::NodeVersionFile); } + // npm-install-checks: "spec 2" (runtime array [bun, node]: the node entry is used) + #[tokio::test] + async fn test_resolve_node_version_dev_engines_array_form() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let package_json = r#"{ + "devEngines": { + "runtime": [ + {"name": "bun", "version": ">= 1.0.0", "onFail": "ignore"}, + {"name": "node", "version": "^22.0.0", "onFail": "error"} + ] + } + }"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "^22.0.0"); + assert_eq!(resolution.source, VersionSource::DevEnginesRuntime); + } + + // npm-install-checks: "invalid name"; runtimes Vite+ does not manage are + // skipped and resolution falls through to engines.node + #[tokio::test] + async fn test_resolve_node_version_dev_engines_without_node_entry_falls_through() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let package_json = r#"{ + "engines": {"node": ">=20.0.0"}, + "devEngines": { + "runtime": [{"name": "deno", "version": "^2.0.0"}] + } + }"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, ">=20.0.0"); + assert_eq!(resolution.source, VersionSource::EnginesNode); + } + + // npm-install-checks: "name only" (no version means any version satisfies); + // the entry imposes no constraint, so resolution falls through to engines.node + #[tokio::test] + async fn test_resolve_node_version_dev_engines_name_only_falls_through() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let package_json = r#"{ + "engines": {"node": ">=20.0.0"}, + "devEngines": {"runtime": {"name": "node"}} + }"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, ">=20.0.0"); + assert_eq!(resolution.source, VersionSource::EnginesNode); + } + #[tokio::test] async fn test_resolve_node_version_none_when_no_sources() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index f7cae64dc7..b117cb3487 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -12,7 +12,8 @@ directories = { workspace = true } nix = { workspace = true, features = ["poll", "term"] } owo-colors = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +# use `preserve_order` feature to preserve the order of the fields in `package.json` +serde_json = { workspace = true, features = ["preserve_order"] } supports-color = "3" tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/vite_shared/src/json_edit.rs b/crates/vite_shared/src/json_edit.rs new file mode 100644 index 0000000000..c27e799f1d --- /dev/null +++ b/crates/vite_shared/src/json_edit.rs @@ -0,0 +1,203 @@ +//! Formatting-preserving JSON editing helpers for package.json. +//! +//! Writes to package.json must be surgical (see rfcs/dev-engines.md): preserve +//! key order (serde_json `preserve_order`), keep the file's existing indentation +//! style (2 spaces, 4 spaces, tabs), and keep the trailing-newline style. + +use serde::Serialize; +use vite_str::Str; + +/// Detected formatting style of a JSON document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JsonStyle { + /// One level of indentation (e.g., " ", " ", or "\t"). + pub indent: Str, + /// Whether the document ends with a trailing newline. + pub trailing_newline: bool, +} + +impl Default for JsonStyle { + fn default() -> Self { + Self { indent: " ".into(), trailing_newline: true } + } +} + +impl JsonStyle { + /// Detect the indentation and trailing-newline style from existing content. + /// + /// The leading whitespace of the first indented line is taken as one + /// indentation level (top-level keys in package.json sit at depth one). + /// Falls back to two-space indentation when no indented line is found. + #[must_use] + pub fn detect(content: &str) -> Self { + let indent = content + .lines() + .find_map(|line| { + let trimmed = line.trim_start(); + if trimmed.is_empty() || trimmed.len() == line.len() { + return None; + } + Some(Str::from(&line[..line.len() - trimmed.len()])) + }) + .unwrap_or_else(|| " ".into()); + Self { indent, trailing_newline: content.is_empty() || content.ends_with('\n') } + } + + /// Serialize a JSON value using this style. + /// + /// # Errors + /// Returns an error if serialization fails. + pub fn to_string_styled(&self, value: &impl Serialize) -> Result { + let formatter = serde_json::ser::PrettyFormatter::with_indent(self.indent.as_bytes()); + let mut out = Vec::new(); + let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter); + value.serialize(&mut serializer)?; + let mut content = String::from_utf8(out).expect("serde_json output is always valid UTF-8"); + if self.trailing_newline { + content.push('\n'); + } + Ok(content) + } +} + +/// Parse `content` as a JSON document, apply `edit` to its top-level object, +/// and serialize back preserving the original formatting style. +/// +/// # Errors +/// Returns an error if `content` is not valid JSON. +pub fn edit_json_object( + content: &str, + edit: impl FnOnce(&mut serde_json::Map), +) -> Result { + let style = JsonStyle::detect(content); + let mut value: serde_json::Value = serde_json::from_str(content)?; + if let Some(obj) = value.as_object_mut() { + edit(obj); + } + style.to_string_styled(&value) +} + +/// Insert `key` into `obj`, placing it right after `after_key` when present, +/// otherwise appending it at the end. +/// +/// If `key` already exists, its value is replaced in place (position preserved). +pub fn insert_after( + obj: &mut serde_json::Map, + after_key: &str, + key: &str, + value: serde_json::Value, +) { + if obj.contains_key(key) { + obj.insert(key.into(), value); + return; + } + if !obj.contains_key(after_key) { + obj.insert(key.into(), value); + return; + } + // Rebuild the map to splice the new key in after `after_key` + // (serde_json's preserve_order map appends on insert). + let entries = std::mem::take(obj); + for (existing_key, existing_value) in entries { + let is_anchor = existing_key == after_key; + obj.insert(existing_key, existing_value); + if is_anchor { + obj.insert(key.into(), value.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_two_space_indent() { + let style = JsonStyle::detect("{\n \"name\": \"a\"\n}\n"); + assert_eq!(style.indent, " "); + assert!(style.trailing_newline); + } + + #[test] + fn test_detect_four_space_indent() { + let style = JsonStyle::detect("{\n \"name\": \"a\"\n}"); + assert_eq!(style.indent, " "); + assert!(!style.trailing_newline); + } + + #[test] + fn test_detect_tab_indent() { + let style = JsonStyle::detect("{\n\t\"name\": \"a\"\n}\n"); + assert_eq!(style.indent, "\t"); + assert!(style.trailing_newline); + } + + #[test] + fn test_detect_defaults() { + let style = JsonStyle::detect("{}"); + assert_eq!(style.indent, " "); + // A document without a final newline keeps that style + assert!(!style.trailing_newline); + + let style = JsonStyle::detect(""); + assert_eq!(style.indent, " "); + assert!(style.trailing_newline); + } + + #[test] + fn test_edit_preserves_key_order_and_style() { + let content = "{\n\t\"name\": \"a\",\n\t\"version\": \"1.0.0\",\n\t\"engines\": {}\n}\n"; + let updated = edit_json_object(content, |obj| { + obj.insert("packageManager".into(), serde_json::json!("pnpm@11.0.0")); + }) + .unwrap(); + assert_eq!( + updated, + "{\n\t\"name\": \"a\",\n\t\"version\": \"1.0.0\",\n\t\"engines\": {},\n\t\"packageManager\": \"pnpm@11.0.0\"\n}\n" + ); + } + + #[test] + fn test_edit_round_trip_without_changes() { + let content = "{\n \"name\": \"a\",\n \"engines\": {\n \"node\": \">=20\"\n }\n}\n"; + let updated = edit_json_object(content, |_| {}).unwrap(); + assert_eq!(updated, content); + } + + #[test] + fn test_insert_after_places_adjacent_to_anchor() { + let content = r#"{"name":"a","engines":{"node":">=20"},"scripts":{}}"#; + let updated = edit_json_object(content, |obj| { + insert_after(obj, "engines", "devEngines", serde_json::json!({})); + }) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let keys: Vec<&str> = value.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys, ["name", "engines", "devEngines", "scripts"]); + } + + #[test] + fn test_insert_after_appends_without_anchor() { + let content = r#"{"name":"a","scripts":{}}"#; + let updated = edit_json_object(content, |obj| { + insert_after(obj, "engines", "devEngines", serde_json::json!({})); + }) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let keys: Vec<&str> = value.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys, ["name", "scripts", "devEngines"]); + } + + #[test] + fn test_insert_after_replaces_existing_in_place() { + let content = r#"{"name":"a","devEngines":{"runtime":{}},"scripts":{}}"#; + let updated = edit_json_object(content, |obj| { + insert_after(obj, "engines", "devEngines", serde_json::json!("replaced")); + }) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&updated).unwrap(); + let keys: Vec<&str> = value.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys, ["name", "devEngines", "scripts"]); + assert_eq!(value["devEngines"], "replaced"); + } +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 121720e050..7ef476143f 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -13,6 +13,7 @@ mod error; pub mod header; mod home; mod http; +mod json_edit; pub mod output; mod package_json; mod path_env; @@ -24,7 +25,10 @@ pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; pub use home::get_vp_home; pub use http::shared_http_client; -pub use package_json::{DevEngines, Engines, PackageJson, RuntimeEngine, RuntimeEngineConfig}; +pub use json_edit::{JsonStyle, edit_json_object, insert_after}; +pub use package_json::{ + DevEngineDependency, DevEngineField, DevEngines, Engines, OnFail, PackageJson, dev_engine_entry, +}; pub use path_env::{ PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend, prepend_to_path_env, diff --git a/crates/vite_shared/src/package_json.rs b/crates/vite_shared/src/package_json.rs index 1e1cd56647..e4f6f276be 100644 --- a/crates/vite_shared/src/package_json.rs +++ b/crates/vite_shared/src/package_json.rs @@ -1,55 +1,167 @@ -//! Package.json parsing utilities for Node.js version resolution. +//! Package.json parsing utilities for development environment resolution. //! -//! This module provides shared types for parsing `devEngines.runtime` and `engines.node` -//! fields from package.json, used across multiple crates for version resolution. +//! This module provides shared types for parsing `devEngines` and `engines.node` +//! fields from package.json, used across multiple crates for Node.js runtime and +//! package manager resolution. +//! +//! The `devEngines` types follow the OpenJS devEngines field proposal: +//! +//! +//! Parsing is intentionally lenient: malformed entries are skipped instead of +//! failing the whole parse, so a bad `devEngines` value never breaks resolution +//! of other fields (see rfcs/dev-engines.md). -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use vite_str::Str; -/// A single runtime engine configuration. -#[derive(Deserialize, Default, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeEngine { - /// The name of the runtime (e.g., "node", "deno", "bun") - #[serde(default)] +/// `onFail` behavior for a devEngines entry, per the devEngines spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnFail { + /// Proceed without action. + Ignore, + /// Print a message and continue. + Warn, + /// Print a message and exit. + Error, + /// Attempt remediation by downloading the required tool/version. + Download, +} + +impl OnFail { + /// Parse an `onFail` string value. + /// + /// Returns `None` for unknown values, which are treated as the positional + /// default (see [`DevEngineField::effective_on_fail`]). + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "ignore" => Some(Self::Ignore), + "warn" => Some(Self::Warn), + "error" => Some(Self::Error), + "download" => Some(Self::Download), + _ => None, + } + } +} + +/// One devEngines dependency entry (spec: `DevEngineDependency`). +#[derive(Debug, Clone)] +pub struct DevEngineDependency { + /// The name of the tool (e.g., "node" for `runtime`, "pnpm" for `packageManager`). pub name: Str, - /// The version requirement (e.g., "^24.4.0") - #[serde(default)] - pub version: Str, - /// Action to take on failure (e.g., "download", "error", "warn") - /// Currently not used but parsed for future use. - #[serde(default)] - pub on_fail: Str, + /// The version requirement as a semver range (e.g., "^24.4.0"). + /// `None` means any version satisfies the requirement. + pub version: Option, + /// Action to take when the requirement is not met. + /// `None` means the positional default applies + /// (see [`DevEngineField::effective_on_fail`]). + pub on_fail: Option, } -/// Runtime field can be a single object or an array. -#[derive(Deserialize, Debug, Clone)] -#[serde(untagged)] -pub enum RuntimeEngineConfig { - /// A single runtime configuration - Single(RuntimeEngine), - /// Multiple runtime configurations - Multiple(Vec), +impl DevEngineDependency { + /// Parse one entry from a JSON value. + /// + /// Returns `None` when the value is not an object or has no usable `name`; + /// such entries are skipped instead of failing the parse. + fn from_value(value: &serde_json::Value) -> Option { + let obj = value.as_object()?; + let name: Str = obj.get("name")?.as_str()?.trim().into(); + if name.is_empty() { + return None; + } + let version = obj + .get("version") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(Str::from); + let on_fail = obj.get("onFail").and_then(|v| v.as_str()).and_then(OnFail::parse); + Some(Self { name, version, on_fail }) + } } -impl RuntimeEngineConfig { - /// Find the first runtime with the given name. +/// A devEngines sub-field, which the spec allows to be a single object or an array. +#[derive(Debug, Clone)] +pub enum DevEngineField { + /// A single dependency configuration. + Single(DevEngineDependency), + /// Multiple dependency configurations; the first acceptable entry wins. + Multiple(Vec), +} + +impl DevEngineField { + /// Parse a sub-field from a JSON value (single object or array of objects). + /// + /// Returns `None` when a single-object form is malformed. In array form, + /// malformed entries are skipped individually. + fn from_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Array(items) => Some(Self::Multiple( + items.iter().filter_map(DevEngineDependency::from_value).collect(), + )), + other => DevEngineDependency::from_value(other).map(Self::Single), + } + } + + /// All entries in declaration order. #[must_use] - pub fn find_by_name(&self, name: &str) -> Option<&RuntimeEngine> { + pub fn entries(&self) -> &[DevEngineDependency] { match self { - Self::Single(engine) if engine.name == name => Some(engine), - Self::Single(_) => None, - Self::Multiple(engines) => engines.iter().find(|e| e.name == name), + Self::Single(dep) => std::slice::from_ref(dep), + Self::Multiple(deps) => deps, } } + + /// Find the first entry with the given name. + #[must_use] + pub fn find_by_name(&self, name: &str) -> Option<&DevEngineDependency> { + self.entries().iter().find(|e| e.name == name) + } + + /// Find the first entry with the given name, along with its index + /// (for [`Self::effective_on_fail`]). + #[must_use] + pub fn find_with_index(&self, name: &str) -> Option<(usize, &DevEngineDependency)> { + self.entries().iter().enumerate().find(|(_, e)| e.name == name) + } + + /// Effective `onFail` for the entry at `index`, per the spec defaults: + /// a single object defaults to `error`; in arrays, every element except + /// the last defaults to `ignore` and the last defaults to `error`. + #[must_use] + pub fn effective_on_fail(&self, index: usize) -> OnFail { + let entries = self.entries(); + let default = if index + 1 >= entries.len() { OnFail::Error } else { OnFail::Ignore }; + entries.get(index).and_then(|e| e.on_fail).unwrap_or(default) + } } /// The devEngines section of package.json. -#[derive(Deserialize, Default, Debug, Clone)] +/// +/// The spec also defines `os`, `cpu`, and `libc` sub-fields; Vite+ does not +/// act on those, so they are not parsed (see rfcs/dev-engines.md, Non-Goals). +#[derive(Default, Debug, Clone)] pub struct DevEngines { - /// Runtime configuration(s) - #[serde(default)] - pub runtime: Option, + /// Runtime configuration(s) (e.g., Node.js). + pub runtime: Option, + /// Package manager configuration(s) (e.g., pnpm). + pub package_manager: Option, +} + +impl<'de> Deserialize<'de> for DevEngines { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Lenient: any non-object shape parses as an empty DevEngines instead of + // failing the whole package.json parse. + let value = serde_json::Value::deserialize(deserializer)?; + let Some(obj) = value.as_object() else { return Ok(Self::default()) }; + Ok(Self { + runtime: obj.get("runtime").and_then(DevEngineField::from_value), + package_manager: obj.get("packageManager").and_then(DevEngineField::from_value), + }) + } } /// The engines section of package.json. @@ -72,6 +184,25 @@ pub struct PackageJson { pub engines: Option, } +impl PackageJson { + /// The `devEngines.runtime` entry for the given runtime name (e.g. `"node"`). + /// + /// This is the single accessor for the spec-defined `devEngines.runtime` + /// shape; callers read `.version` / `.on_fail` from the returned entry. + #[must_use] + pub fn dev_engines_runtime(&self, name: &str) -> Option<&DevEngineDependency> { + self.dev_engines.as_ref()?.runtime.as_ref()?.find_by_name(name) + } +} + +/// Build a `devEngines` dependency entry (`{ name, version, onFail: "download" }`) +/// as a JSON value, the canonical shape Vite+ writes when pinning a runtime or +/// package manager (see rfcs/dev-engines.md). +#[must_use] +pub fn dev_engine_entry(name: &str, version: &str) -> serde_json::Value { + serde_json::json!({ "name": name, "version": version, "onFail": "download" }) +} + #[cfg(test)] mod tests { use super::*; @@ -94,8 +225,8 @@ mod tests { let node = runtime.find_by_name("node").unwrap(); assert_eq!(node.name, "node"); - assert_eq!(node.version, "^24.4.0"); - assert_eq!(node.on_fail, "download"); + assert_eq!(node.version.as_deref(), Some("^24.4.0")); + assert_eq!(node.on_fail, Some(OnFail::Download)); assert!(runtime.find_by_name("deno").is_none()); } @@ -125,11 +256,11 @@ mod tests { let node = runtime.find_by_name("node").unwrap(); assert_eq!(node.name, "node"); - assert_eq!(node.version, "^24.4.0"); + assert_eq!(node.version.as_deref(), Some("^24.4.0")); let deno = runtime.find_by_name("deno").unwrap(); assert_eq!(deno.name, "deno"); - assert_eq!(deno.version, "^2.4.3"); + assert_eq!(deno.version.as_deref(), Some("^2.4.3")); assert!(runtime.find_by_name("bun").is_none()); } @@ -149,6 +280,7 @@ mod tests { let pkg: PackageJson = serde_json::from_str(json).unwrap(); let dev_engines = pkg.dev_engines.unwrap(); assert!(dev_engines.runtime.is_none()); + assert!(dev_engines.package_manager.is_none()); } #[test] @@ -167,8 +299,186 @@ mod tests { let node = runtime.find_by_name("node").unwrap(); assert_eq!(node.name, "node"); - assert!(node.version.is_empty()); - assert!(node.on_fail.is_empty()); + // Missing version means any version satisfies (spec) + assert!(node.version.is_none()); + assert!(node.on_fail.is_none()); + } + + #[test] + fn test_parse_empty_version_treated_as_none() { + let json = r#"{ + "devEngines": { + "runtime": {"name": "node", "version": " "} + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + assert!(runtime.find_by_name("node").unwrap().version.is_none()); + } + + #[test] + fn test_parse_single_package_manager() { + let json = r#"{ + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "^11.0.0", + "onFail": "download" + } + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let pm = dev_engines.package_manager.unwrap(); + + let pnpm = pm.find_by_name("pnpm").unwrap(); + assert_eq!(pnpm.name, "pnpm"); + assert_eq!(pnpm.version.as_deref(), Some("^11.0.0")); + assert_eq!(pnpm.on_fail, Some(OnFail::Download)); + } + + #[test] + fn test_parse_package_manager_array() { + let json = r#"{ + "devEngines": { + "packageManager": [ + {"name": "pnpm", "version": "^11.0.0"}, + {"name": "npm", "version": ">=10"} + ] + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let pm = pkg.dev_engines.unwrap().package_manager.unwrap(); + + assert_eq!(pm.entries().len(), 2); + let (index, pnpm) = pm.find_with_index("pnpm").unwrap(); + assert_eq!(index, 0); + assert_eq!(pnpm.version.as_deref(), Some("^11.0.0")); + let (index, npm) = pm.find_with_index("npm").unwrap(); + assert_eq!(index, 1); + assert_eq!(npm.version.as_deref(), Some(">=10")); + } + + #[test] + fn test_effective_on_fail_single_defaults_to_error() { + let json = r#"{ + "devEngines": { + "runtime": {"name": "node", "version": "^24.0.0"} + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + assert_eq!(runtime.effective_on_fail(0), OnFail::Error); + } + + #[test] + fn test_effective_on_fail_array_defaults() { + // Prior elements default to ignore, the final element defaults to error + let json = r#"{ + "devEngines": { + "packageManager": [ + {"name": "pnpm"}, + {"name": "yarn"}, + {"name": "npm"} + ] + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let pm = pkg.dev_engines.unwrap().package_manager.unwrap(); + assert_eq!(pm.effective_on_fail(0), OnFail::Ignore); + assert_eq!(pm.effective_on_fail(1), OnFail::Ignore); + assert_eq!(pm.effective_on_fail(2), OnFail::Error); + } + + #[test] + fn test_effective_on_fail_explicit_wins_over_positional_default() { + let json = r#"{ + "devEngines": { + "packageManager": [ + {"name": "pnpm", "onFail": "error"}, + {"name": "npm", "onFail": "warn"} + ] + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let pm = pkg.dev_engines.unwrap().package_manager.unwrap(); + assert_eq!(pm.effective_on_fail(0), OnFail::Error); + assert_eq!(pm.effective_on_fail(1), OnFail::Warn); + } + + #[test] + fn test_unknown_on_fail_treated_as_positional_default() { + let json = r#"{ + "devEngines": { + "runtime": {"name": "node", "onFail": "explode"} + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + assert!(runtime.find_by_name("node").unwrap().on_fail.is_none()); + assert_eq!(runtime.effective_on_fail(0), OnFail::Error); + } + + #[test] + fn test_malformed_entries_skipped_in_array() { + let json = r#"{ + "devEngines": { + "packageManager": [ + "not-an-object", + {"version": "^1.0.0"}, + {"name": ""}, + {"name": "pnpm", "version": "^11.0.0"} + ] + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let pm = pkg.dev_engines.unwrap().package_manager.unwrap(); + assert_eq!(pm.entries().len(), 1); + assert_eq!(pm.entries()[0].name, "pnpm"); + } + + #[test] + fn test_malformed_single_entry_parses_as_absent() { + let json = r#"{ + "devEngines": { + "runtime": "node@24" + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert!(pkg.dev_engines.unwrap().runtime.is_none()); + } + + #[test] + fn test_malformed_dev_engines_does_not_break_engines_parse() { + let json = r#"{ + "engines": {"node": ">=20.0.0"}, + "devEngines": "nonsense" + }"#; + + 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(); + assert!(dev_engines.runtime.is_none()); + assert!(dev_engines.package_manager.is_none()); + } + + #[test] + fn test_on_fail_parse() { + assert_eq!(OnFail::parse("ignore"), Some(OnFail::Ignore)); + assert_eq!(OnFail::parse("warn"), Some(OnFail::Warn)); + assert_eq!(OnFail::parse("error"), Some(OnFail::Error)); + assert_eq!(OnFail::parse("download"), Some(OnFail::Download)); + assert_eq!(OnFail::parse("Download"), None); + assert_eq!(OnFail::parse(""), None); } #[test] @@ -196,6 +506,158 @@ mod tests { 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"); + assert_eq!(node.version.as_deref(), Some("^24.4.0")); + } + + // npm-install-checks: "noop options" / "empty array along side error" + #[test] + fn test_parse_empty_array_field() { + let json = r#"{ + "devEngines": {"runtime": [], "packageManager": []} + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + let pm = dev_engines.package_manager.unwrap(); + // an empty array imposes no constraint + assert!(runtime.entries().is_empty()); + assert!(runtime.find_by_name("node").is_none()); + assert!(pm.entries().is_empty()); + } + + // npm-install-checks: "tests non-object" (invalid devEngines); npm throws, + // Vite+ is lenient on read (rfcs/dev-engines.md) and parses as empty + #[test] + fn test_parse_dev_engines_non_object_values() { + for value in ["1", "true", "false", "null", "[]", "[[]]", "[1]", "\"text\""] { + let json = format!(r#"{{"engines": {{"node": ">=20.0.0"}}, "devEngines": {value}}}"#); + let pkg: PackageJson = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("devEngines {value} should parse leniently: {e}")); + if value == "null" { + assert!(pkg.dev_engines.is_none(), "devEngines {value}"); + } else { + let dev_engines = pkg.dev_engines.unwrap(); + assert!(dev_engines.runtime.is_none(), "devEngines {value}"); + assert!(dev_engines.package_manager.is_none(), "devEngines {value}"); + } + // other fields keep parsing + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into()), "devEngines {value}"); + } + } + + // npm-install-checks: "tests non-object" (invalid engine property); npm throws, + // Vite+ skips the unusable value + #[test] + fn test_parse_field_non_object_values() { + // single non-object value parses as absent + for value in ["1", "true", "false", "null", "\"node@24\""] { + let json = format!(r#"{{"devEngines": {{"runtime": {value}}}}}"#); + let pkg: PackageJson = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("runtime {value} should parse leniently: {e}")); + assert!(pkg.dev_engines.unwrap().runtime.is_none(), "runtime {value}"); + } + + // array elements that are not objects are skipped individually + let json = r#"{ + "devEngines": { + "runtime": [1, true, false, null, [], [[]], {"name": "node", "version": "^24.0.0"}] + } + }"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + assert_eq!(runtime.entries().len(), 1); + assert_eq!(runtime.entries()[0].name, "node"); + } + + // npm-install-checks: "invalid name value" (non-string); npm throws, + // Vite+ skips the entry (a usable string name is required) + #[test] + fn test_parse_non_string_name_skipped() { + for value in ["1", "true", "false", "null", "{}", "[]"] { + let json = format!( + r#"{{"devEngines": {{"runtime": {{"name": {value}, "version": "^24.0.0"}}}}}}"# + ); + let pkg: PackageJson = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("name {value} should parse leniently: {e}")); + assert!(pkg.dev_engines.unwrap().runtime.is_none(), "name {value}"); + } + } + + // npm-install-checks: "invalid version value" (non-string); npm throws, + // Vite+ treats it as any version satisfies. `"version": 22` (a number) is a + // real-world typo worth pinning down. + #[test] + fn test_parse_non_string_version_treated_as_any() { + for value in ["22", "true", "false", "null", "{}", "[]"] { + let json = format!( + r#"{{"devEngines": {{"runtime": {{"name": "node", "version": {value}}}}}}}"# + ); + let pkg: PackageJson = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("version {value} should parse leniently: {e}")); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + let node = runtime.find_by_name("node").unwrap(); + assert!(node.version.is_none(), "version {value}"); + } + } + + // npm-install-checks: "invalid onFail value" (non-string); npm throws, + // Vite+ falls back to the positional default + #[test] + fn test_parse_non_string_on_fail_treated_as_positional_default() { + for value in ["1", "true", "false", "null", "{}", "[]"] { + let json = format!( + r#"{{"devEngines": {{"runtime": {{"name": "node", "onFail": {value}}}}}}}"# + ); + let pkg: PackageJson = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("onFail {value} should parse leniently: {e}")); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + assert!(runtime.find_by_name("node").unwrap().on_fail.is_none(), "onFail {value}"); + assert_eq!(runtime.effective_on_fail(0), OnFail::Error, "onFail {value}"); + } + } + + // npm-install-checks: "current name does not match, wanted has extra attribute"; + // npm throws on unknown entry properties, Vite+ ignores them + #[test] + fn test_parse_extra_properties_ignored() { + let json = r#"{ + "devEngines": { + "runtime": { + "name": "node", + "version": "^24.0.0", + "extra": "test-extra", + "another": {"nested": true} + } + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let runtime = pkg.dev_engines.unwrap().runtime.unwrap(); + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.version.as_deref(), Some("^24.0.0")); + } + + // npm-install-checks: "unrecognized property"; npm throws, Vite+ ignores + // sub-fields it does not act on (os/cpu/libc are out of scope per the RFC) + #[test] + fn test_parse_unknown_sub_fields_ignored() { + let json = r#"{ + "devEngines": { + "os": {"name": "darwin"}, + "cpu": [{"name": "arm"}, {"name": "x86"}], + "libc": {"name": "glibc"}, + "unrecognized": {"name": "alpha", "version": "1"}, + "runtime": {"name": "node", "version": "^24.0.0"}, + "packageManager": {"name": "pnpm", "version": "^11.0.0"} + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + assert_eq!(runtime.find_by_name("node").unwrap().version.as_deref(), Some("^24.0.0")); + let pm = dev_engines.package_manager.unwrap(); + assert_eq!(pm.find_by_name("pnpm").unwrap().version.as_deref(), Some("^11.0.0")); } } diff --git a/docs/guide/env.md b/docs/guide/env.md index 975140b2c4..fdf791b41f 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -6,7 +6,16 @@ Managed mode is on by default, so `node`, `npm`, and related shims resolve through Vite+ and pick the right Node.js version for the current project. -When a project declares `packageManager` in `package.json`, matching package-manager shims also use that exact package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. +The project Node.js version is resolved from these sources, in priority order: + +1. `.node-version` file (current or parent directories) +2. `devEngines.runtime` in `package.json` (the [devEngines standard](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines)) +3. `engines.node` in `package.json` +4. The global default (`vp env default`), then the latest LTS + +`devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. + +When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. @@ -60,8 +69,8 @@ In CI, `vp env use` can still run without shell initialization. It writes a temp ### Manage - `vp env default` sets or shows the global default Node.js version -- `vp env pin` pins a Node.js version in the current directory -- `vp env unpin` removes `.node-version` from the current directory +- `vp env pin` pins a Node.js version in the current directory: an existing `.node-version` keeps being updated; otherwise the pin is written to `package.json#devEngines.runtime`; `.node-version` is only created when the directory has no `package.json`. Use `--target node-version` or `--target dev-engines` to choose explicitly. An existing `engines.node` is never modified. +- `vp env unpin` removes the pin from the same source `vp env pin` would write - `vp env use` sets a Node.js version for the current shell session - `vp env install` installs a Node.js version - `vp env uninstall` removes an installed Node.js version @@ -78,7 +87,7 @@ In CI, `vp env use` can still run without shell initialization. It writes a temp ## Project Setup -- Pin a project version with `.node-version` +- Pin a project version with `vp env pin` - Use `vp install`, `vp dev`, and `vp build` normally - Let Vite+ pick the right runtime for the project diff --git a/docs/guide/install.md b/docs/guide/install.md index 6a28727201..3b6fc2c424 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -9,18 +9,37 @@ Use Vite+ to manage dependencies across pnpm, npm, Yarn, and Bun. Instead of swi Vite+ detects the package manager from the workspace root in this order: 1. `packageManager` in `package.json` -2. `pnpm-workspace.yaml` -3. `pnpm-lock.yaml` -4. `yarn.lock` or `.yarnrc.yml` -5. `package-lock.json` -6. `bun.lock` or `bun.lockb` -7. `.pnpmfile.cjs` or `pnpmfile.cjs` -8. `bunfig.toml` -9. `yarn.config.cjs` - -If none of those files are present, `vp` falls back to `pnpm` by default. Vite+ automatically downloads the matching package manager and uses it for the command you ran. - -The explicit `packageManager` field also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. +2. `devEngines.packageManager` in `package.json` +3. `pnpm-workspace.yaml` +4. `pnpm-lock.yaml` +5. `yarn.lock` or `.yarnrc.yml` +6. `package-lock.json` +7. `bun.lock` or `bun.lockb` +8. `.pnpmfile.cjs` or `pnpmfile.cjs` +9. `bunfig.toml` +10. `yarn.config.cjs` + +If none of those files are present, `vp` falls back to `pnpm` by default. Vite+ automatically downloads the matching package manager and uses it for the command you ran. When detection comes from lockfiles or config files, the resolved version is written to `devEngines.packageManager` so future runs are deterministic; projects that already declare `packageManager` or `devEngines.packageManager` are left as-is. + +The [`devEngines.packageManager`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) field accepts a single object or an array of objects, and its `version` may be a semver range: + +```json +{ + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "^11.0.0", + "onFail": "download" + } + } +} +``` + +A range resolves to an already-downloaded satisfying version when possible, otherwise to the latest satisfying version from the npm registry. The range itself stays the source of truth; Vite+ never freezes it into an exact `packageManager` pin. When both `packageManager` and `devEngines.packageManager` are declared, the `packageManager` field drives selection and Vite+ warns when it does not satisfy the devEngines constraint (`vp env doctor` shows details). + +Vite+ currently downloads the declared package manager (the `onFail: "download"` behavior); the other `onFail` values are accepted but not yet differentiated. + +The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. ## Usage diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index 41d9444805..ceda95331a 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -145,7 +145,7 @@ pub async fn detect_workspace(cwd: String) -> Result { let workspace_root_path = workspace_root.path.as_path().to_string_lossy().to_string(); match get_package_manager_type_and_version(&workspace_root, None) { - Ok((package_manager_type, version, _)) => Ok(DetectWorkspaceResult { + Ok((package_manager_type, version, _, _)) => Ok(DetectWorkspaceResult { package_manager_name: Some(package_manager_type.to_string()), package_manager_version: Some(version.to_string()), is_monorepo, diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index 91b0d6159e..07e4a4ec64 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -356,8 +356,8 @@ Setup: Manage: default Set or show the global default Node.js version - pin Pin a Node.js version in the current directory (creates .node-version) - unpin Remove the .node-version file from current directory (alias for `pin --unpin`) + pin Pin a Node.js version in the current directory + unpin Remove the Node.js pin from the current directory (alias for `pin --unpin`) use Use a specific Node.js version for this shell session install Install a Node.js version [aliases: i] uninstall Uninstall a Node.js version [aliases: uni] diff --git a/packages/cli/snap-tests-global/command-env-doctor-dev-engines/.node-version b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/packages/cli/snap-tests-global/command-env-doctor-dev-engines/package.json b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/package.json new file mode 100644 index 0000000000..59f979aa75 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/package.json @@ -0,0 +1,16 @@ +{ + "name": "command-env-doctor-dev-engines", + "version": "1.0.0", + "private": true, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "^11.0.0" + }, + "runtime": { + "name": "node", + "version": "^24.0.0" + } + }, + "packageManager": "npm@10.5.0" +} diff --git a/packages/cli/snap-tests-global/command-env-doctor-dev-engines/snap.txt b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/snap.txt new file mode 100644 index 0000000000..eb2e017cec --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/snap.txt @@ -0,0 +1,5 @@ +> vp env doctor > doctor.txt 2>&1; node -e "const fs=require('node:fs');const text=fs.readFileSync('doctor.txt','utf8').replace(/\u001b\[[0-9;]*m/g,'');const lines=text.split('\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\n'));" # print only the deterministic devEngines section of vp env doctor (the other sections are environment-dependent) +devEngines + ⚠ Runtime .node-version (20.18.0) does not satisfy devEngines.runtime "^24.0.0" + ⚠ PackageManager packageManager is "npm@" but devEngines.packageManager requires "pnpm" + note: This will become an error in a future release. diff --git a/packages/cli/snap-tests-global/command-env-doctor-dev-engines/steps.json b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/steps.json new file mode 100644 index 0000000000..285e37de29 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-doctor-dev-engines/steps.json @@ -0,0 +1,7 @@ +{ + "env": {}, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env doctor > doctor.txt 2>&1; node -e \"const fs=require('node:fs');const text=fs.readFileSync('doctor.txt','utf8').replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));\" # print only the deterministic devEngines section of vp env doctor (the other sections are environment-dependent)" + ] +} diff --git a/packages/cli/snap-tests-global/command-install-auto-create-package-json/snap.txt b/packages/cli/snap-tests-global/command-install-auto-create-package-json/snap.txt index eddce3053f..ccc942b871 100644 --- a/packages/cli/snap-tests-global/command-install-auto-create-package-json/snap.txt +++ b/packages/cli/snap-tests-global/command-install-auto-create-package-json/snap.txt @@ -4,8 +4,15 @@ no package.json > vp install --silent && cat package.json # should auto-create package.json and install { "type": "module", - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } + > vp add testnpm2 -D && cat package.json # should add package to auto-created package.json Packages: + + @@ -17,8 +24,14 @@ devDependencies: Done in ms using pnpm v { "type": "module", - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "devDependencies": { "testnpm2": "^1.0.1" } -} \ No newline at end of file +} diff --git a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt index c86a5f7de4..4d77bf05c0 100644 --- a/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt +++ b/packages/cli/snap-tests-global/fallback-invalid-engines-to-dev-engines/snap.txt @@ -1,10 +1,8 @@ > vp exec node -e "console.log(process.version)" # Should use devEngines.runtime 22.12.0, not LTS warning: invalid version 'invalid' in engines.node, ignoring -warning: invalid version 'invalid' in engines.node, ignoring v > vp env which node # Should show devEngines.runtime source -warning: invalid version 'invalid' in engines.node, ignoring /js_runtime/node//bin/node Version: Source: /package.json diff --git a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt index a3f2242548..41af05c3ec 100644 --- a/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-add-git-hooks/snap.txt @@ -12,7 +12,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 00f949c269..312d5927f3 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -42,7 +42,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index 608076c519..5fe49316c0 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -45,7 +45,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt index bf72628953..e26f94cecb 100644 --- a/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-chained-lint-staged-pre-commit/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt index ce8aa8021e..13435e190f 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-custom-dir/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt index 0b78cecfe4..102046cb59 100644 --- a/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-composed-husky-prepare/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt index 1aa424701f..63e82d65e9 100644 --- a/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-env-prefix-lint-staged/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt index b36a35cd0e..ca28151e46 100644 --- a/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-jsplugins-preserve/snap.txt @@ -16,7 +16,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat vite.config.ts # lint.jsPlugins keeps `eslint-plugin-survives`; lint.rules keeps `survives/no-fiction` diff --git a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt index cb4a72420f..21a7cb5060 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lint-staged/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt index a62041e3a1..0889d48721 100644 --- a/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-lintstagedrc/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt index ce804c1e0b..e15d79dd1c 100644 --- a/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-npx-wrapper/snap.txt @@ -20,7 +20,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt index 445a9ec158..f1c55628ee 100644 --- a/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-nuxt-skip/snap.txt @@ -25,7 +25,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > test -f eslint.config.mjs # eslint config file is NOT deleted \ No newline at end of file diff --git a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt index 0275e5301a..e8e6cf08f6 100644 --- a/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-plugins-cleanup/snap.txt @@ -23,7 +23,13 @@ "vue": "^3.5.0", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > test ! -f eslint.config.mjs # check eslint config is removed diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt index 0464c8ed89..9dea8eb1f3 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-dual-config/snap.txt @@ -25,7 +25,13 @@ ESLint comments replaced "devDependencies": { "vite-plus": "latest" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > test ! -f eslint.config.mjs # check flat config is removed diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt index f172f96835..976477a0a6 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun-mjs/snap.txt @@ -23,7 +23,13 @@ ESLint comments replaced "devDependencies": { "vite-plus": "latest" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > test ! -f eslint.config.mjs # check eslint config is removed diff --git a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt index d616fdeeae..bec40844e3 100644 --- a/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-rerun/snap.txt @@ -23,7 +23,13 @@ ESLint comments replaced "devDependencies": { "vite-plus": "latest" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > test ! -f eslint.config.mjs # check eslint config is removed diff --git a/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt b/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt index 18c0ded362..b722e38f14 100644 --- a/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint-type-aware/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat vite.config.ts # check options.typeAware/typeCheck = true is set in the lint block diff --git a/packages/cli/snap-tests-global/migration-eslint/snap.txt b/packages/cli/snap-tests-global/migration-eslint/snap.txt index 636589b8c2..0eebe7a0ee 100644 --- a/packages/cli/snap-tests-global/migration-eslint/snap.txt +++ b/packages/cli/snap-tests-global/migration-eslint/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt index 067d68aba6..8259f6bc78 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-lint-staged/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt index 72ac97116d..998eb30281 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-hooks/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt index e9c4d445c6..fd5a5b547a 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky-v8-multi-hooks/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt index 260a9e510c..4fe839f50c 100644 --- a/packages/cli/snap-tests-global/migration-existing-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-husky/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt index bc0f54cc72..39372d494b 100644 --- a/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-lint-staged-config/snap.txt @@ -12,7 +12,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt index 96c9e19d94..1af81eb934 100644 --- a/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-pnpm-exec-lint-staged/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt index 3df5a5ee3e..79a98435ee 100644 --- a/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt +++ b/packages/cli/snap-tests-global/migration-existing-prepare-script/snap.txt @@ -16,7 +16,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt index cff5f38ba1..4d4968b06c 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown-json-config/snap.txt @@ -40,7 +40,13 @@ export default defineConfig({ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog @@ -99,5 +105,11 @@ export default defineConfig({ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } diff --git a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt index 2da336e742..635777ab9d 100644 --- a/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-tsdown/snap.txt @@ -42,7 +42,13 @@ export default defineConfig({ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog @@ -102,5 +108,11 @@ export default defineConfig({ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } diff --git a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt index 0c3d5fe6f3..596fb1b7f5 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-config/snap.txt @@ -45,7 +45,13 @@ export default defineConfig({ "playwright": "*", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt index 0b688b13bf..3c309fbe4f 100644 --- a/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt +++ b/packages/cli/snap-tests-global/migration-from-vitest-files/snap.txt @@ -20,7 +20,13 @@ "playwright": "*", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt index 2fde12c7ed..6e36c11985 100644 --- a/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt +++ b/packages/cli/snap-tests-global/migration-hooks-skip-on-existing-hookspath/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt index 374eca6e6a..0fcbd13c57 100644 --- a/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-catalog-version/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt index 4d39f8f03b..a5e24d28ce 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag-v9-installed/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt index 88721e1e3a..9b9bb748b8 100644 --- a/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-latest-dist-tag/snap.txt @@ -17,7 +17,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt index a98449620e..c9ba844284 100644 --- a/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-or-prepare/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt index 42df326a40..9660233cbf 100644 --- a/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-semicolon-prepare/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt index d56772a22d..eb08f0c8f0 100644 --- a/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-husky-v8-preserves-lint-staged/snap.txt @@ -21,7 +21,13 @@ "lint-staged": { "*.{js,ts}": "eslint --fix" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt index 86da8a68b4..641ea5522d 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-in-scripts/snap.txt @@ -16,7 +16,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt index 910f89ebd1..c997b0d867 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-merge-fail/snap.txt @@ -21,7 +21,13 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu "lint-staged": { "*.css": "stylelint --fix" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt index 0644922652..1f401bd846 100644 --- a/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-lint-staged-ts-config/snap.txt @@ -19,7 +19,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index c3d4d437b4..26d3da551f 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -85,7 +85,13 @@ Documentation: https://viteplus.dev/guide/migrate "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt index dc4b6e426a..b4eff71f6f 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-merge-fail/snap.txt @@ -18,7 +18,13 @@ Please add staged config to vite.config.ts manually, see https://viteplus.dev/gu "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 8171e7d079..42d5acbb1b 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -32,7 +32,13 @@ export default { "lint-staged": "^16.2.6", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt index 0bc94af760..eaccf39880 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-staged-exists/snap.txt @@ -13,7 +13,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt index 45ed37520d..0936ac3162 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-js/snap.txt @@ -46,7 +46,13 @@ export default { "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt index 90189bea77..6f359ec29f 100644 --- a/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt +++ b/packages/cli/snap-tests-global/migration-merge-vite-config-ts/snap.txt @@ -79,7 +79,13 @@ export default defineConfig({ "playwright": "*", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index dfe73cacba..558eda8de8 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -32,7 +32,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt index c31403fd00..d75196d32f 100644 --- a/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-git-repo/snap.txt @@ -10,7 +10,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt index 74aa2b4f85..8b27356cbf 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks-with-husky/snap.txt @@ -20,7 +20,13 @@ "lint-staged": { "*.ts": "eslint --fix" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt index 925cb2962f..c3ae0f65c7 100644 --- a/packages/cli/snap-tests-global/migration-no-hooks/snap.txt +++ b/packages/cli/snap-tests-global/migration-no-hooks/snap.txt @@ -11,7 +11,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt index eef56238fe..5cb0715789 100644 --- a/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt +++ b/packages/cli/snap-tests-global/migration-other-hook-tool/snap.txt @@ -23,7 +23,13 @@ "lint-staged": { "*.ts": "eslint --fix" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index b140b1981d..721e849ec2 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -40,7 +40,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index 88d3d51bac..4d39bdc8a1 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -42,7 +42,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt index ec77acdd83..c98c420300 100644 --- a/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt +++ b/packages/cli/snap-tests-global/migration-partially-migrated-pre-commit/snap.txt @@ -18,7 +18,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt index 58db2eeebd..0379130d56 100644 --- a/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-eslint-combo/snap.txt @@ -22,7 +22,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt index 9bae37b1c3..9c0b20c101 100644 --- a/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-ignore-unknown/snap.txt @@ -20,7 +20,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt index 4f54aaf230..e3fcec9896 100644 --- a/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-lint-staged/snap.txt @@ -17,7 +17,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt index f788ee36e4..ecebd3fbbc 100644 --- a/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier-pkg-json/snap.txt @@ -18,7 +18,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-prettier/snap.txt b/packages/cli/snap-tests-global/migration-prettier/snap.txt index a9064ccaed..16e259d3f4 100644 --- a/packages/cli/snap-tests-global/migration-prettier/snap.txt +++ b/packages/cli/snap-tests-global/migration-prettier/snap.txt @@ -20,7 +20,13 @@ Prettier configuration detected. Auto-migrating to Oxfmt... "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index 20088a9b48..552748805f 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -40,7 +40,13 @@ declare module 'vite-plus' { "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt index eaf7a8b8e1..1fe3bc5c63 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-dependency/snap.txt @@ -35,7 +35,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 2110d21ce5..8739cd00b2 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -35,7 +35,13 @@ export default defineConfig({ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index f68c634e75..71b2547c19 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -21,7 +21,13 @@ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat foo/vite.config.ts # check vite.config.ts diff --git a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt index fbdf9b973a..bca6478bef 100644 --- a/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt +++ b/packages/cli/snap-tests-global/migration-tsconfig-esmoduleinterop/snap.txt @@ -32,7 +32,13 @@ export default defineConfig({ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "scripts": { "prepare": "vp config" } diff --git a/packages/cli/snap-tests-global/migration-vite-version/snap.txt b/packages/cli/snap-tests-global/migration-vite-version/snap.txt index 042250b04c..4d6f2fe59e 100644 --- a/packages/cli/snap-tests-global/migration-vite-version/snap.txt +++ b/packages/cli/snap-tests-global/migration-vite-version/snap.txt @@ -15,7 +15,13 @@ "vite": "catalog:", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog diff --git a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt index 588753876e..04dcaa3390 100644 --- a/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt +++ b/packages/cli/snap-tests-global/migration-vitest-peer-dep/snap.txt @@ -18,7 +18,13 @@ "vite-plus": "catalog:", "vitest": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat pnpm-workspace.yaml diff --git a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt index 67acd459b0..dd574fdecf 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo-bun/snap.txt @@ -31,10 +31,16 @@ vite.config.ts "vite": "catalog:", "vitest": "catalog:" }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "", + "onFail": "download" + } + }, "engines": { "node": ">=22.12.0" }, - "packageManager": "bun@", "catalog": { "vite": "npm:@voidzero-dev/vite-plus-core@latest", "vitest": "npm:@voidzero-dev/vite-plus-test@latest", diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 5b1a1d3094..5ff645a6e2 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -23,10 +23,16 @@ vite.config.ts "devDependencies": { "vite-plus": "catalog:" }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "engines": { "node": ">=22.12.0" - }, - "packageManager": "pnpm@" + } } > cat vite-plus-monorepo/vite.config.ts # check vite config has cache enabled @@ -243,8 +249,14 @@ vite.config.ts "devDependencies": { "vite-plus": "catalog:" }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "engines": { "node": ">=22.12.0" - }, - "packageManager": "pnpm@" + } } diff --git a/packages/cli/snap-tests/command-install-auto-create-package-json/snap.txt b/packages/cli/snap-tests/command-install-auto-create-package-json/snap.txt index eddce3053f..ccc942b871 100644 --- a/packages/cli/snap-tests/command-install-auto-create-package-json/snap.txt +++ b/packages/cli/snap-tests/command-install-auto-create-package-json/snap.txt @@ -4,8 +4,15 @@ no package.json > vp install --silent && cat package.json # should auto-create package.json and install { "type": "module", - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } + > vp add testnpm2 -D && cat package.json # should add package to auto-created package.json Packages: + + @@ -17,8 +24,14 @@ devDependencies: Done in ms using pnpm v { "type": "module", - "packageManager": "pnpm@", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + }, "devDependencies": { "testnpm2": "^1.0.1" } -} \ No newline at end of file +} diff --git a/packages/cli/snap-tests/create-org-bundled/snap.txt b/packages/cli/snap-tests/create-org-bundled/snap.txt index 8518d0425f..3f64ead59a 100644 --- a/packages/cli/snap-tests/create-org-bundled/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled/snap.txt @@ -14,7 +14,13 @@ "devDependencies": { "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } > cat my-demo-app/src/index.ts # verify bundled source copied diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index be510b2697..d927f4460e 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -31,6 +31,7 @@ const { rewriteEslintPackageJson, detectIncompatibleEslintIntegration, preflightGitHooksSetup, + setPackageManager, } = await import('../migrator.js'); describe('rewritePackageJson', () => { @@ -771,6 +772,79 @@ describe('parseNvmrcVersion', () => { }); }); +describe('setPackageManager', () => { + let tmpDir: string; + + const downloadResult = { + name: 'pnpm', + installDir: '/tmp/install', + binPrefix: '/tmp/install/bin', + packageName: 'pnpm', + version: '11.5.1', + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const readPkg = () => + JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')) as Record< + string, + unknown + >; + + it('writes devEngines.packageManager when neither field exists', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x' }, null, 2)); + setPackageManager(tmpDir, downloadResult); + expect(readPkg().devEngines).toEqual({ + packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, + }); + }); + + it('keeps an existing packageManager field untouched', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'x', packageManager: 'npm@10.5.0' }, null, 2), + ); + setPackageManager(tmpDir, downloadResult); + const pkg = readPkg(); + expect(pkg.packageManager).toBe('npm@10.5.0'); + expect(pkg.devEngines).toBeUndefined(); + }); + + it('preserves an existing devEngines.runtime entry', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify( + { name: 'x', devEngines: { runtime: { name: 'node', version: '^24.0.0' } } }, + null, + 2, + ), + ); + setPackageManager(tmpDir, downloadResult); + expect(readPkg().devEngines).toEqual({ + runtime: { name: 'node', version: '^24.0.0' }, + packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, + }); + }); + + it('replaces a malformed devEngines value instead of spreading it', () => { + // spreading a string would corrupt the field with numeric index keys + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'x', devEngines: 'oops' }, null, 2), + ); + setPackageManager(tmpDir, downloadResult); + expect(readPkg().devEngines).toEqual({ + packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, + }); + }); +}); + describe('detectNodeVersionManagerFile', () => { let tmpDir: string; diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 627e8c100f..ce24565ff3 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -3319,10 +3319,31 @@ export function setPackageManager( projectDir: string, downloadPackageManager: DownloadPackageManagerResult, ) { - // set package manager - editJsonFile<{ packageManager?: string }>(path.join(projectDir, 'package.json'), (pkg) => { - if (!pkg.packageManager) { - pkg.packageManager = `${downloadPackageManager.name}@${downloadPackageManager.version}`; + // Set the package manager pin. Compatibility-first rule (rfcs/dev-engines.md): + // an existing `packageManager` field or `devEngines.packageManager` declaration + // is the source of truth and is left as-is; otherwise the exact resolved version + // is written to `devEngines.packageManager` (the recommended standard field). + editJsonFile<{ + packageManager?: string; + devEngines?: { packageManager?: unknown; [key: string]: unknown }; + }>(path.join(projectDir, 'package.json'), (pkg) => { + if (!pkg.packageManager && !pkg.devEngines?.packageManager) { + // Only spread a well-formed object: spreading a malformed devEngines value + // (string/array) would corrupt the field with numeric index keys + const devEngines = + typeof pkg.devEngines === 'object' && + pkg.devEngines !== null && + !Array.isArray(pkg.devEngines) + ? pkg.devEngines + : undefined; + pkg.devEngines = { + ...devEngines, + packageManager: { + name: downloadPackageManager.name, + version: downloadPackageManager.version, + onFail: 'download', + }, + }; } return pkg; }); diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index 76fca7cf45..c24b3c767d 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -35,6 +35,24 @@ bar@v1.0.0 expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); }); + test('replace devEngines.packageManager pinned versions', () => { + // prerelease identifiers with hyphens and build metadata are normalized too + for (const version of ['11.5.1', '11.5.1-rc-1', '11.5.1+sha.abc']) { + const json = [ + '{', + ' "devEngines": {', + ' "packageManager": {', + ' "name": "pnpm",', + ` "version": "${version}",`, + ' "onFail": "download"', + ' }', + ' }', + '}', + ].join('\n'); + expect(replaceUnstableOutput(json)).toContain('"version": ""'); + } + }); + test('replace date', () => { const output = ` Start at 15:01:23 diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 4ffbf0a5bd..6ade18759d 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -49,6 +49,13 @@ export function replaceUnstableOutput(output: string, cwd?: string) { // e.g.: ` v1.0.0` -> ` ` // e.g.: `/1.0.0` -> `/` .replaceAll(/([@/\s]v?)\d+\.\d+\.\d+(?:-.*)?/g, '$1') + // devEngines.packageManager auto-pin writes the exact resolved version + // e.g.: `"name": "pnpm",\n "version": "11.5.1"` -> `"version": ""` + // (the optional suffix covers prerelease and build metadata: -rc-1, +sha.abc) + .replaceAll( + /("name": "(?:pnpm|npm|yarn|bun)",\s*\n\s*"version": ")\d+\.\d+\.\d+(?:[-+][\w.+-]+)?(")/g, + '$1$2', + ) // vite build banner can appear on some environments/runtimes: // vite v // transforming...✓ ... diff --git a/rfcs/dev-engines.md b/rfcs/dev-engines.md new file mode 100644 index 0000000000..abd6e37ea5 --- /dev/null +++ b/rfcs/dev-engines.md @@ -0,0 +1,463 @@ +# RFC: `devEngines` Support for Runtime and Package Manager Selection + +## Summary + +Make `package.json#devEngines` a first-class source for both Node.js runtime selection and package manager selection in Vite+, following the [OpenJS devEngines field proposal](https://github.com/openjs-foundation/package-metadata-interoperability-working-group/blob/main/devengines-field-proposal.md), with a **compatibility-first** rule: + +- **Existing projects**: the current source of truth keeps winning for writes. An existing `.node-version` keeps being updated by `vp env pin`; an existing top-level `packageManager` keeps being updated by package-manager pinning (Corepack compatibility). +- **New projects**: `devEngines.runtime` and `devEngines.packageManager` become the recommended and default manifest. +- **Conflicts**: conflicting declarations are surfaced by `vp env doctor` (semver-aware), never silently resolved. + +This RFC implements the plan agreed in [#864](https://github.com/voidzero-dev/vite-plus/issues/864), incorporating review notes from that thread (semver-aware conflict detection, range preservation for `devEngines.packageManager`, handling of all field shapes defined by the spec). + +## Motivation + +`devEngines` is the cross-tool standard for declaring development environment requirements, already supported by npm (v10.9+), pnpm (`devEngines.runtime` for Node management), and Corepack. Vite+ currently: + +- Reads `devEngines.runtime` for Node resolution, but at the lowest project-file priority and without honoring `onFail`. +- Ignores `devEngines.packageManager` entirely (TODO at `crates/vite_install/src/package_manager.rs:288`). Worse, in a project that intentionally uses `devEngines.packageManager` plus a lockfile, today's auto-pin writes a redundant top-level `packageManager` field into `package.json`, fighting the user's chosen manifest. +- Only ever writes `.node-version` (`vp env pin`) and `packageManager` (auto-pin, `vp create`, `vp migrate`), so users standardizing on `devEngines` get no write-path support. + +Community feedback in #864 asks Vite+ to treat `devEngines` as the standard going forward while not breaking existing `.node-version` / `packageManager` workflows. + +## Background + +### The devEngines specification + +Per the [OpenJS proposal](https://github.com/openjs-foundation/package-metadata-interoperability-working-group/blob/main/devengines-field-proposal.md): + +```typescript +interface DevEngines { + os?: DevEngineDependency | DevEngineDependency[]; + cpu?: DevEngineDependency | DevEngineDependency[]; + libc?: DevEngineDependency | DevEngineDependency[]; + runtime?: DevEngineDependency | DevEngineDependency[]; + packageManager?: DevEngineDependency | DevEngineDependency[]; +} + +interface DevEngineDependency { + name: string; // required + version?: string; // semver range, same syntax as engines.node; absent = any + onFail?: 'ignore' | 'warn' | 'error' | 'download'; // default: error +} +``` + +Spec semantics that matter for this RFC: + +- `version` is **optional**; absent means any version satisfies. +- `version` uses **semver range syntax** (like `engines.node`). LTS aliases (`lts/*`, `lts/iron`) are not valid values. +- `onFail` defaults to `error`. In **array form**, the first acceptable option is used; prior elements default to `ignore` and only the final element defaults to `error`. +- Each sub-field accepts a single object or an array of objects. + +### Current Vite+ behavior + +**Node.js resolution chain** (`crates/vite_global_cli/src/commands/env/config.rs`, `crates/vite_js_runtime/src/runtime.rs`): + +1. `VITE_PLUS_NODE_VERSION` env var (session) +2. `~/.vite-plus/.session-node-version` (session) +3. `.node-version` (walk up) +4. `package.json#engines.node` (walk up) +5. `package.json#devEngines.runtime[name="node"]` (walk up) +6. User default (`~/.vite-plus/config.json`) +7. Latest LTS + +**Package manager detection chain** (`crates/vite_install/src/package_manager.rs`; [rfcs/package-manager-detection.md](./package-manager-detection.md) has been updated alongside this RFC and now documents the new chain): + +1. `packageManager` field (exact version, optional hash) +2. Lockfiles (`pnpm-workspace.yaml`, `pnpm-lock.yaml`, `yarn.lock`, ...) at version `latest` +3. Config files (`.pnpmfile.cjs`, `bunfig.toml`, ...) at version `latest` +4. Explicit default / interactive selection + +**Write paths today**: + +- `vp env pin` writes `.node-version` only (`crates/vite_global_cli/src/commands/env/pin.rs`). +- After downloading a package manager resolved from `latest`, `PackageManagerBuilder::build()` auto-writes the exact version into the `packageManager` field (`set_package_manager_field()`). +- `vp create` / `vp migrate` write `packageManager` when absent (`packages/cli/src/migration/migrator.ts#setPackageManager`). +- `vp migrate` converts `.nvmrc` / Volta pins into `.node-version`. + +**Existing parsing** (`crates/vite_shared/src/package_json.rs`): `DevEngines` has only `runtime`; `RuntimeEngine { name, version, on_fail }` where all fields default to empty strings; `on_fail` is parsed but unused. + +## Guiding Principle: Compatibility First + +> Existing `.node-version` wins for Node.js. Existing `packageManager` wins for package manager. New projects use `devEngines.runtime` and `devEngines.packageManager`. Conflicting declarations are surfaced by `vp env doctor`, not silently resolved. + +Every design decision below derives from this rule. + +## Detailed Design + +### 1. Shared parsing: spec-compliant `DevEngines` + +Generalize `crates/vite_shared/src/package_json.rs`: + +```rust +/// One devEngines dependency entry (spec: DevEngineDependency). +pub struct DevEngineDependency { + pub name: Str, // required by spec + pub version: Option, // optional; None = any version satisfies + pub on_fail: Option, // optional; effective default depends on position +} + +pub enum OnFail { Ignore, Warn, Error, Download } + +/// Single object or array (spec allows both for every sub-field). +pub enum DevEngineField { + Single(DevEngineDependency), + Multiple(Vec), +} + +pub struct DevEngines { + pub runtime: Option, + pub package_manager: Option, + // os / cpu / libc: not parsed; out of scope (see Non-Goals) +} +``` + +Parsing rules: + +- **Lenient on read**: a malformed entry (missing `name`, unknown `onFail` value, invalid JSON shape for one entry) is skipped with a warning; it never aborts resolution or breaks unrelated commands. This matches the existing `normalize_version` "warn and ignore" behavior. +- **Effective `onFail`**: single object defaults to `error`; in arrays, every element except the last defaults to `ignore`, the last defaults to `error` (spec). +- **`version` absent or empty**: treated as "any version satisfies". For resolution purposes the entry imposes no version constraint. +- **Unknown `onFail` strings**: treated as the positional default, with a warning. + +`RuntimeEngineConfig` / `RuntimeEngine` are replaced by (or aliased to) the new generic types so runtime and packageManager share one implementation, as suggested in #864 (handle "the different shapes of each field" once). + +### 2. Node.js runtime + +#### 2.1 Read priority + +Proposed chain (change marked): + +1. `VITE_PLUS_NODE_VERSION` env var (session) +2. `.session-node-version` (session) +3. `.node-version` (walk up) +4. **`package.json#devEngines.runtime[name="node"]` (walk up)** (moved above `engines.node`) +5. `package.json#engines.node` (walk up) +6. User default +7. Latest LTS + +Rationale for swapping 4 and 5: `engines.node` is a consumer-facing support range (often broad, e.g. `>=18`), while `devEngines.runtime` is by definition the development-environment requirement and is the field npm/pnpm act on for dev tooling. When both exist, the dev-specific field should drive the dev runtime. This was raised in #864 and matches pnpm behavior. + +Decision: the reorder lands together with this RFC. Compatibility impact: only projects declaring **both** fields with **disagreeing** resolutions change behavior, and `vp env doctor` flags exactly those projects. + +The walk-up algorithm is unchanged: at each directory, sources are checked in the order above before moving to the parent. + +#### 2.2 Array form and non-node runtimes + +- Entries are evaluated in array order; the first entry with `name == "node"` is used (matches spec "first acceptable option" for the runtimes Vite+ manages). +- Entries for runtimes Vite+ does not manage (`deno`, `bun`, ...) are skipped for resolution. `vp env doctor` lists them as informational notes ("declared runtime `deno` is not managed by Vite+"). +- If no `node` entry exists, the chain falls through to `engines.node`. + +#### 2.3 `onFail` semantics + +> **Status:** As of this PR, `runtime.onFail` is **parsed and preserved but not yet acted on**. Managed mode always resolves and downloads the requested version (equivalent to `onFail: "download"`), and a resolution/download failure surfaces as an error regardless of the declared `onFail`. The matrix below is the intended future behavior, tracked under [Deferred / Future Work](#deferred--future-work). + +The intended semantics, once implemented: managed mode already implements the strongest remediation (`download`), so `onFail` mainly matters when remediation is impossible or when system-first mode (`vp env off`) is active: + +| `onFail` | Managed mode (`vp env on`, default) | System-first mode (`vp env off`) | +| ----------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `ignore` | Entry skipped for resolution; chain continues | Entry skipped; system Node used | +| `warn` | Resolve and download as usual; if that fails, warn and continue the chain | If system Node does not satisfy the range: warn, then use system Node anyway | +| `error` (default) | Resolve and download as usual; if that fails, exit with error | If system Node does not satisfy the range: exit with error | +| `download` | Resolve and download (Vite+ default behavior) | If system Node does not satisfy the range: fall back to managed download | + +Notes: + +- "If that fails" covers: the version/range does not exist upstream, or the download fails (network, platform). +- The current default managed-mode UX is unchanged: a plain `{ "name": "node", "version": "^24.0.0" }` behaves exactly like today (resolve and download). + +#### 2.4 `vp env pin` write target + +`vp env pin ` selects its write target in the current directory: + +| State of cwd | Write target | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------- | +| `.node-version` exists | Update `.node-version` (unchanged behavior) | +| No `.node-version`; `package.json` has a `devEngines.runtime` node entry | Update that entry's `version` (preserve `onFail`, sibling entries) | +| No `.node-version`; `package.json` exists without a node runtime entry | Add `devEngines.runtime` node entry with `onFail: "download"` | +| No `package.json` in cwd | Create `.node-version` (unchanged behavior; nothing else to write to) | + +- `engines.node` is **never** a pin target: it is a consumer-facing constraint, and rewriting it would change the published package contract. More broadly, no Vite+ write path (pin, unpin, auto-pin, create, migrate) ever deletes or modifies an existing `engines.node`; it is always kept unchanged. +- When updating an existing node entry in array form, only that entry's `version` changes; other runtimes and `onFail` values are preserved. +- An explicit `--target` flag overrides the selection: `vp env pin 24 --target node-version` or `--target dev-engines`. The flag always wins: `--target dev-engines` writes `devEngines.runtime` even when `.node-version` exists, with a note that `.node-version` still takes resolution precedence until removed. + +Value semantics (matching the implemented `vp env pin` behavior, which resolves +every input to an exact version at pin time; identical for both targets): + +| Input | Written to the target (either `.node-version` or `devEngines.runtime.version`) | +| ----------------- | ------------------------------------------------------------------------------ | +| `24.11.1` (exact) | `24.11.1` (validated against the registry) | +| `24` (partial) | resolved exact at pin time (e.g. `24.11.1`) | +| `^24.0.0` (range) | resolved exact at pin time | +| `lts` / `latest` | resolved exact at pin time | + +Pinning always writes exact versions ("pin" means lock down; this is today's +`.node-version` behavior, kept for both targets). Teams that prefer a range in +`devEngines.runtime` edit `package.json` directly; Vite+ reads and preserves +ranges, it just does not author them through `vp env pin`. The alias-to-semver +conversion table in section 5.2 applies to `vp migrate` (which preserves the +floating intent of `.nvmrc` values), not to pin. + +Rule: Vite+ is lenient when **reading** (`devEngines.runtime.version` containing an alias still resolves, with a doctor warning about spec non-compliance) but strict when **writing** (only valid semver ranges or exact versions are ever written to `devEngines`). + +When `.node-version` is the write target and a `devEngines.runtime` node entry also exists: + +- If the new pinned version **satisfies** the declared range, nothing else changes (the range is still honest). +- If it does **not** satisfy: in an interactive terminal, `vp env pin` prompts to sync (`devEngines.runtime ("^24.0.0") is no longer satisfied. Update it to match? [Y/n]`), consistent with pin's existing overwrite confirmation. In non-interactive environments it warns and points at `vp env doctor`. It never rewrites the other source without confirmation. + +#### 2.5 `vp env pin` (show) and `vp env unpin` + +- `vp env pin` with no argument reports the active pin and its source, now including `devEngines.runtime` as a possible source (the `VersionSource::DevEnginesRuntime` display string already exists). Inherited pins from parent directories are reported for both sources, checking `.node-version` first and then the `devEngines.runtime` node entry per directory (matching the resolution order). +- `vp env unpin` / `vp env pin --unpin` removes the pin from the same target that `vp env pin` would write: delete `.node-version` if present, otherwise remove the node entry from `devEngines.runtime` (removing the `devEngines.runtime` key entirely if it becomes empty, and `devEngines` if it becomes empty). + +### 3. Package manager + +#### 3.1 Detection priority + +New chain (insertion marked): + +1. `packageManager` field (exact version, optional hash; unchanged) +2. **`devEngines.packageManager` (new)** +3. Lockfiles (unchanged) +4. Config files (unchanged) +5. Explicit default / interactive selection (unchanged) + +When **both** `packageManager` and `devEngines.packageManager` exist: + +- The `packageManager` field drives selection (it is exact and hash-verifiable, and this matches Corepack precedence). +- If the field's name or version does not satisfy the `devEngines.packageManager` constraint, the command prints a one-line warning and `vp env doctor` reports details. The warning notes that this becomes a hard error in a future release (warn-now, error-later transition). npm already errors in this situation, so npm-driven projects get hard enforcement today. + +#### 3.2 Resolving a `devEngines.packageManager` entry + +- `name` must be one of `pnpm`, `yarn`, `npm`, `bun`. For other names (the spec leaves the namespace open): in array form, skip to the next entry; if no usable entry remains, apply the effective `onFail` of the last entry (`error` → fail with a clear message; `ignore`/`warn` → continue down the detection chain). `download` for an unsupported manager is an error. +- `version` may be exact, a range, or absent: + - Exact (`11.5.1`): used directly (same as the `packageManager` field path, minus hash). + - Range (`^11.0.0`) or absent (= any): + 1. If an already-downloaded version under `$VP_HOME/package_manager//` satisfies the range, use the highest satisfying one (offline-friendly, no network). + 2. Otherwise resolve the latest satisfying version from the npm registry, fetching the abbreviated metadata document (`Accept: application/vnd.npm.install-v1+json`, KBs instead of the multi-MB full packument) and download it. + - Once a satisfying version is downloaded, step 1 short-circuits every later resolution, so the registry is only consulted while no satisfying version is cached (no separate TTL cache needed). + - Prereleases are excluded from range resolution, except when the requirement itself contains a prerelease marker (e.g. `^12.0.0-0`) and no stable version satisfies it. +- `onFail` (current PR): acted on **only when no array entry names a supported package manager** (the bullet above) - `ignore`/`warn` continue down the detection chain, `error`/`download` fail. Once a supported entry is selected, its `onFail` is **not yet** consulted: a later unresolved range or download/install failure surfaces as an error rather than falling back to the next entry. Per-entry fallback (try each supported entry in order, applying its effective `onFail` on failure) is tracked under [Deferred / Future Work](#deferred--future-work). + +#### 3.3 Auto-pin behavior changes + +Today: whenever the detected version was `latest` (lockfile/config/interactive detection), Vite+ writes the exact downloaded version into the `packageManager` field. + +Proposed: + +| Detection source | Auto-write behavior | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `packageManager` field | No write needed (already exact); unchanged | +| `devEngines.packageManager` exact | No write needed | +| `devEngines.packageManager` range | **No write.** The range is the user's chosen source of truth; freezing it into `packageManager` would create a second, conflicting source (#864 review note) | +| Lockfile / config / interactive | Write the exact resolved version to **`devEngines.packageManager`** with `onFail: "download"` (new default target), instead of the `packageManager` field | + +The last row is the "new projects default to devEngines" rule applied to the auto-pin path. Projects that already have a `packageManager` field never hit this row, so Corepack-pinned repos keep their current behavior. Decision: the auto-pin value is **exact** (preserving today's determinism guarantee); teams that prefer a range can edit the field afterwards, and Vite+ preserves it (range sources are never frozen). + +Auto-written shape: + +```json +{ + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "11.5.1", + "onFail": "download" + } + } +} +``` + +Auto-pin never replaces entries it did not write: when `devEngines.packageManager` already declares entries Vite+ does not act on (e.g. another package manager with `onFail: "ignore"` whose detection fell through to a lockfile), the resolved entry is appended to an existing array, and an existing single entry is converted to array form with the original entry kept first. A single entry is only written when the field is absent or malformed. + +#### 3.4 Surfacing the source + +- `vp env --current --json` gains `"source": "devEngines.packageManager"` as a possible value in the `package_manager` block (alongside the existing `"packageManager"`). +- `vp env which pnpm` and friends report the resolution source the same way they do for Node. + +### 4. `vp env doctor` conflict detection + +All checks are **semver-aware**: an exact version satisfying a declared range is not a conflict (`.node-version: 24.11.1` is compatible with `devEngines.runtime.version: ^24.0.0`; #864 review note). + +Compatible coexistence of `.node-version` and `devEngines.runtime` is intentionally **not** flagged (not even as a note). The two are a legitimate interop pattern, not a redundancy to clean up: `.node-version` is read by fnm/nvm/asdf/Netlify/`actions/setup-node`, while `devEngines.runtime` is the npm/pnpm standard, so projects keep both on purpose. Doctor warns only when the two actually diverge (a real conflict), which also catches later drift when, for example, `.node-version` is bumped out of the declared range. + +Which package.json each check examines mirrors the consumer it diagnoses: + +- **Runtime checks** use nearest-first walk-up semantics, like Node.js resolution. The `.node-version` vs `devEngines.runtime` conflict check follows the resolution walk on both sides: it fires only when a `.node-version` actually wins resolution, and the `devEngines.runtime` declaration is found in ancestor manifests too (a parent `.node-version` shadowed by a nearer winning `devEngines.runtime` is not a conflict). +- **Package-manager checks** examine the **workspace root** package.json: that is the file `vp install` reads for `packageManager` / `devEngines.packageManager`, which in a monorepo can be a different (higher) file than the nearest package.json. + +New checks: + +| Check | Severity | Example message | +| --------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------- | +| `.node-version` does not satisfy `devEngines.runtime[node].version` | warn | `.node-version (22.13.0) does not satisfy devEngines.runtime "^24.0.0"` | +| Resolved Node version does not satisfy `engines.node` | warn | (extends the existing `check_version_compatibility` warning) | +| `packageManager` name differs from `devEngines.packageManager` name | warn | `packageManager is "npm@11.4.0" but devEngines.packageManager requires "pnpm"` | +| `packageManager` version does not satisfy `devEngines.packageManager` range | warn | `packageManager pnpm@10.9.0 does not satisfy devEngines.packageManager "^11.0.0"` | +| `devEngines.runtime.version` is not a valid semver range (e.g. `lts/*`) | warn | `devEngines.runtime.version "lts/*" is not a valid semver range (see devEngines spec)` | +| Malformed `devEngines` entry (missing `name`, unknown `onFail`) | warn | `devEngines.packageManager entry is missing "name" and was ignored` | +| Runtime entries Vite+ does not manage (`deno`, ...) | info | `devEngines.runtime declares "deno", which is not managed by Vite+` | +| Unsupported `devEngines.packageManager` name | warn | `devEngines.packageManager "vlt" is not supported (supported: pnpm, yarn, npm, bun)` | + +Doctor never auto-fixes; it explains which source wins under the precedence rules and what to change. + +### 5. `vp create` and `vp migrate` + +#### 5.1 `vp create` (new projects) + +- Templates (`packages/cli/templates/*/package.json`) gain a `devEngines` block; `setPackageManager()` writes `devEngines.packageManager` (instead of the `packageManager` field) when the project declares neither: + +```json +{ + "devEngines": { + "runtime": { + "name": "node", + "version": "^24.0.0", + "onFail": "download" + }, + "packageManager": { + "name": "pnpm", + "version": "11.5.1", + "onFail": "download" + } + } +} +``` + +- Decision (revised in review): templates keep their existing `engines.node` entry (`>=22.12.0`) **unchanged** and add the `devEngines` block alongside it. `engines.node` stays the broadly-understood consumer-facing floor (CI images, Renovate, Netlify, pnpm enforcement); `devEngines.runtime` carries the dev requirement (e.g. the current LTS major, `^24.0.0`, as shown above). Doctor guards against drift between the two. +- Creating inside an existing workspace keeps honoring the workspace's existing source of truth (unchanged precedence from [rfcs/package-manager-detection.md](./package-manager-detection.md)). + +#### 5.2 `vp migrate` (existing projects) + +Same precedence rule as `vp env pin`: + +- Project already has `.node-version`: keep it (today's behavior). +- Project has `packageManager`: keep updating that field (today's behavior). +- `.nvmrc` / Volta migration targets `devEngines.runtime`; the prompt names the destination ("Migrate .nvmrc to devEngines.runtime?"). Valid semver values transfer verbatim; alias values are **converted to semver at migration time** (decision from review): + +| Source value (`.nvmrc`) | Written to `devEngines.runtime.version` | Note | +| --------------------------- | ------------------------------------------- | -------------------------------------------------------------------- | +| `20.18.0` / `v20.18.0` | `20.18.0` | Exact, verbatim (`v` prefix stripped) | +| `20` / `20.18` / `^20.0.0` | verbatim | Already valid semver ranges | +| `lts/iron` (codename) | `^20.0.0` (the codename's major line) | Same release line, faithful conversion | +| `lts/*` | `^.0.0` (e.g. `^24.0.0`) | Loses float to future LTS lines; migration output notes this | +| `lts/-1`, `lts/-2` (offset) | `^.0.0` | Offset resolved at migration time | +| `latest` / `node` | exact version resolved at migration time | No semver equivalent of "always newest"; migration output notes this | + +- Volta's `volta.node` is always exact, so it migrates verbatim. + +### 6. JSON editing fidelity + +Writes to `package.json` must be surgical: + +- Preserve key order (serde_json `preserve_order` is already enabled for `set_package_manager_field`). +- Detect and preserve the file's existing indentation (2 spaces, 4 spaces, tabs) and trailing-newline style instead of unconditionally `to_string_pretty`. +- When adding `devEngines`, place it adjacent to `engines` when present, otherwise append at the end. +- The TypeScript side reuses the existing `editJsonFile` helper. + +A small shared Rust helper (in `vite_shared`) will own "edit one field in package.json, preserving formatting", used by pin, auto-pin, and unpin. + +## Spec Compliance Matrix + +| Spec feature | Vite+ support after this RFC | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `runtime` (single + array) | Yes, for `name: "node"`; other runtimes surfaced by doctor, not managed | +| `packageManager` (single + array) | Yes, for pnpm / yarn / npm / bun | +| `version` optional, semver range syntax | Yes (lenient read, strict write) | +| `onFail` (`ignore` / `warn` / `error` / `download`) | Partial: drives the unsupported-name fallthrough for `packageManager`; otherwise parsed/preserved, not yet acted on (see section 2.3 and [Deferred / Future Work](#deferred--future-work)) | +| Array `onFail` defaults (prior `ignore`, last `error`) | Yes | +| `os` / `cpu` / `libc` | Out of scope (possible future doctor checks) | +| Integrity hash | Not part of the spec; the `packageManager` field hash remains supported | + +## Non-Goals + +- Managing non-Node runtimes (`deno`, `bun` as a runtime) via `devEngines.runtime`. +- Validating `devEngines.os` / `cpu` / `libc`. +- Acting as a general enforcement layer for arbitrary package manager names beyond pnpm / yarn / npm / bun. +- Changing session-override behavior (`vp env use`, `VITE_PLUS_NODE_VERSION`). + +## Deferred / Future Work + +`onFail` is parsed, preserved, and validated (`vp env doctor` flags unknown values), but the full behavioral matrix is **not yet implemented** in this PR. What is and isn't acted on today: + +| Field | `onFail` behavior today | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `runtime` | Not acted on. Managed mode always resolves and downloads (equivalent to `download`); failures error out regardless of `onFail`. The section 2.3 matrix is future work. | +| `packageManager` | Acted on only when no array entry names a supported package manager: `ignore`/`warn` continue the detection chain, `error`/`download` fail. A selected (supported) entry's `onFail` is not consulted on a later resolve/download failure. | + +The deferred behavior, in priority order: + +1. **Per-entry `packageManager` fallback.** Try each supported array entry in order; when an entry's range cannot be resolved or its version fails to download/install, apply that entry's effective `onFail` (`ignore`/`warn` advance to the next entry or fall through the detection chain; `error` fails). A supported entry is always tried before its `onFail` is consulted (i.e. `onFail` never skips an entry pre-emptively). +2. **Runtime `onFail` matrix** (section 2.3): differentiate `ignore`/`warn`/`error`/`download` in managed and system-first modes for `devEngines.runtime`, in shim dispatch and `vp env use`. + +Both are intentionally separated from this PR: the per-entry fallback threads `onFail` through the async download path, and the runtime matrix touches version resolution and the shim dispatch hot path. Until they land, the doc surfaces above describe only the implemented subset. + +## Compatibility Impact Summary + +| Scenario | Before | After | +| ---------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------ | +| Project with `.node-version` | `.node-version` wins; pin updates it | Unchanged | +| Project with `packageManager` field | Field wins; no auto-write | Unchanged (plus consistency warning if devEngines conflicts) | +| Project with `devEngines.packageManager` + lockfile | devEngines ignored; auto-pin **injects** `packageManager` | devEngines drives selection; no injected field | +| Project with lockfile only (neither field) | Auto-pin writes `packageManager` exact | Auto-pin writes `devEngines.packageManager` exact | +| Project with both `engines.node` and `devEngines.runtime`, disagreeing | `engines.node` wins | `devEngines.runtime` wins; doctor warns | +| `vp env pin` in a dir with `package.json`, no `.node-version` | Creates `.node-version` | Writes `devEngines.runtime` | +| `vp migrate` of `.nvmrc` / Volta pins | Creates `.node-version` | Writes `devEngines.runtime` (aliases converted to semver) | + +## Implementation Plan + +### Phase 1: Shared parsing and JSON editing + +1. Generalize `crates/vite_shared/src/package_json.rs` to the spec-compliant `DevEngineDependency` / `DevEngineField` / `OnFail` types; add `package_manager` to `DevEngines`; lenient-parse rules; effective-`onFail` computation; unit tests for every spec shape (single, array, missing version, missing onFail, malformed entries). +2. Add the formatting-preserving package.json edit helper to `vite_shared`. + +### Phase 2: Package manager detection + +1. Insert `devEngines.packageManager` into `get_package_manager_type_and_version()` (replacing the TODO at `crates/vite_install/src/package_manager.rs:288`); name validation; array handling; `onFail` handling. +2. Range resolution against downloaded versions, with registry fallback via the npm abbreviated metadata document. +3. Suppress auto-write when the source is `devEngines.packageManager`; retarget auto-pin to `devEngines.packageManager` when neither field exists. +4. Consistency warning when `packageManager` and `devEngines.packageManager` disagree (warn-now, error-later transition messaging). +5. Expose the new source through the NAPI binding and `vp env --current --json`. + +### Phase 3: `vp env` commands + +1. `vp env pin` target selection, `--target` flag, value rules, sync prompt (TTY) / warning (non-interactive); `vp env unpin` symmetric removal. +2. Runtime read-priority reorder. +3. ~~`onFail` matrix in shim dispatch and `vp env use` / system-first paths.~~ Deferred: runtime `onFail` is parsed but not yet acted on (see [Deferred / Future Work](#deferred--future-work)). +4. All new `vp env doctor` checks. + +### Phase 4: create / migrate + +1. Template `devEngines` blocks (alongside the existing `engines.node`, which stays unchanged); retarget `setPackageManager()`. +2. `.nvmrc` / Volta migration to `devEngines.runtime`, including the alias-to-semver conversion table. + +### Phase 5: Documentation and tests + +1. Update `docs/guide/env.md`, `docs/guide/install.md`, `docs/config/*` as applicable. +2. ~~Update [rfcs/package-manager-detection.md](./package-manager-detection.md) (move `devEngines.packageManager` from Future Enhancements into the algorithm) and [rfcs/env-command.md](./env-command.md) (resolution chain).~~ Done alongside this RFC, together with [rfcs/js-runtime.md](./js-runtime.md) and [rfcs/migration-command.md](./migration-command.md). +3. Snap tests (local and global) covering: pin into devEngines, pin with existing `.node-version`, unpin from devEngines, install with `devEngines.packageManager` (exact, range, array, unsupported name, conflict with `packageManager` field), doctor conflict output, create/migrate output. +4. Rust unit tests alongside the existing suites in `package_manager.rs` and `package_json.rs`. + +Phases 1 to 3 are the core; 4 and 5 can land in follow-up PRs. + +## Resolved Questions + +Decisions from RFC review (2026-06-04): + +| # | Question | Decision | +| --- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `devEngines.runtime` vs `engines.node` read priority | Move `devEngines.runtime` above `engines.node`, landing with this RFC; doctor flags the projects where behavior changes | +| 2 | Auto-pin target and value when neither field exists | Write `devEngines.packageManager` with the exact resolved version and `onFail: "download"` | +| 3 | Pin when both `.node-version` and `devEngines.runtime` exist | Update `.node-version`; if the devEngines range is broken, prompt to sync in interactive terminals, warn in non-interactive environments | +| 4 | Pin override flag | `--target node-version` / `--target dev-engines`; an explicit flag always wins, even when the other source exists | +| 5 | Unsupported `devEngines.packageManager` names | `onFail`-driven: `ignore`/`warn` continue down the detection chain; `error` (the default) and `download` fail with a clear message | +| 6 | Template `engines.node` | Revised: existing `engines.node` is never deleted or modified anywhere; templates keep it unchanged and add `devEngines` alongside it | +| 7 | Migration target for `.nvmrc` / Volta | `devEngines.runtime`; alias values are converted to semver at migration time (see the conversion table in section 5.2) | +| 8 | `packageManager` vs `devEngines.packageManager` conflict severity | Warn now with a notice that it becomes an error in a future release, then flip to hard error | + +## References + +- Issue: [voidzero-dev/vite-plus#864](https://github.com/voidzero-dev/vite-plus/issues/864) (plan: [comment](https://github.com/voidzero-dev/vite-plus/issues/864#issuecomment-4582332165), review notes from @TheAlexLichter) +- Spec: [OpenJS devEngines field proposal](https://github.com/openjs-foundation/package-metadata-interoperability-working-group/blob/main/devengines-field-proposal.md), [discussion #15](https://github.com/openjs-foundation/package-metadata-interoperability-working-group/issues/15) +- npm: [package.json devEngines docs](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) +- pnpm: [devEngines.runtime support](https://github.com/pnpm/pnpm/issues/8153) +- Related RFCs: [package-manager-detection.md](./package-manager-detection.md), [env-command.md](./env-command.md), [js-runtime.md](./js-runtime.md) diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 64a687a3ac..a9c8a6367a 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -288,8 +288,8 @@ argv[0] = "npx" → Shim mode: resolve version, exec npx │ │ (walk up directory tree) │ │ 0. VITE_PLUS_NODE_VERSION │ │ │ └──────────────┬───────────────┘ │ 1. .session-node-version │ │ │ │ │ 2. .node-version │ │ -│ │ │ 3. package.json#engines │ │ -│ │ │ 4. package.json#devEngines │ │ +│ │ │ 3. package.json#devEngines │ │ +│ │ │ 4. package.json#engines │ │ │ │ │ 5. User default (config) │ │ │ │ │ 6. Latest LTS │ │ │ ▼ └─────────────────────────────┘ │ @@ -546,13 +546,13 @@ When resolving which Node.js version to use, vite-plus checks the following sour - Checked in current directory, then parent directories - Simple format: one version per file -3. **`package.json#engines.node`** +3. **`package.json#devEngines.runtime`** - Checked in current directory, then parent directories - - Standard npm constraint field + - Development-environment requirement field (see [RFC: devEngines Support](./dev-engines.md)) -4. **`package.json#devEngines.runtime`** +4. **`package.json#engines.node`** - Checked in current directory, then parent directories - - npm RFC-compliant development engines spec + - Consumer-facing npm constraint field 5. **User default** (`~/.vite-plus/config.json`) - Set via `vp env default ` @@ -811,8 +811,8 @@ The resolution order is: 1. `VITE_PLUS_NODE_VERSION` env var (session override) 2. `.session-node-version` file (session override) 3. `.node-version` in current or parent directories -4. `package.json#engines.node` in current or parent directories -5. `package.json#devEngines.runtime` in current or parent directories +4. `package.json#devEngines.runtime` in current or parent directories +5. `package.json#engines.node` in current or parent directories 6. **User Default**: Configured via `vp env default ` (stored in `~/.vite-plus/config.json`) 7. **System Default**: Latest LTS version @@ -1396,7 +1396,7 @@ Run 'vp install -g typescript' to reinstall. ## Pin Command -The `vp env pin` command provides per-directory Node.js version pinning by managing `.node-version` files. +The `vp env pin` command provides per-directory Node.js version pinning. The write target follows the compatibility-first rule from [RFC: devEngines Support](./dev-engines.md): an existing `.node-version` keeps being updated; otherwise the pin is written to `package.json#devEngines.runtime` (creating the node entry with `onFail: "download"` when absent); `.node-version` is only created when the directory has no `package.json`. An explicit `--target node-version` / `--target dev-engines` flag overrides the selection. ### Behavior @@ -1427,12 +1427,22 @@ $ vp env pin Pinned version: 20.18.0 Source: /Users/user/projects/my-app/.node-version -# If no .node-version in current directory but found in parent +# Pinned via devEngines.runtime in the current directory's package.json +$ vp env pin +Pinned version: 24.1.0 + Source: /Users/user/projects/my-app/package.json (devEngines.runtime) + +# If no pin in current directory but found in a parent (.node-version or +# devEngines.runtime, checked in resolution order per directory) $ vp env pin No version pinned in current directory. Inherited: 22.13.0 from /Users/user/projects/.node-version -# If no .node-version anywhere +$ vp env pin +No version pinned in current directory. + Inherited: ^24.0.0 from /Users/user/projects/package.json (devEngines.runtime) + +# If no pin anywhere $ vp env pin No version pinned. Using default: 20.18.0 (from ~/.vite-plus/config.json) @@ -1449,24 +1459,29 @@ $ vp env unpin ✓ Removed .node-version from /Users/user/projects/my-app ``` +`vp env unpin` removes the pin from the same source that `vp env pin` would write: it deletes `.node-version` when present, otherwise it removes the node entry from `package.json#devEngines.runtime`. + ### Version Format Support -| Input | Written to File | Behavior | -| --------- | --------------- | -------------------------------- | -| `20.18.0` | `20.18.0` | Exact version | -| `20.18` | `20.18` | Latest 20.18.x at runtime | -| `20` | `20` | Latest 20.x.x at runtime | -| `lts` | `22.13.0` | Resolved at pin time | -| `latest` | `24.0.0` | Resolved at pin time | -| `^20.0.0` | `^20.0.0` | Semver range resolved at runtime | +| Input | Written to the target | Behavior | +| --------- | --------------------- | ---------------------------------------------- | +| `20.18.0` | `20.18.0` | Exact version (validated against the registry) | +| `20.18` | e.g. `20.18.3` | Resolved to exact at pin time | +| `20` | e.g. `20.19.0` | Resolved to exact at pin time | +| `lts` | e.g. `22.13.0` | Resolved to exact at pin time | +| `latest` | e.g. `24.0.0` | Resolved to exact at pin time | +| `^20.0.0` | e.g. `20.19.0` | Resolved to exact at pin time | + +Both write targets receive the same exact resolved version; the devEngines spec only allows semver range syntax in `devEngines.runtime.version`, and exact versions satisfy that. See [RFC: devEngines Support](./dev-engines.md). ### Flags -| Flag | Description | -| -------------- | ------------------------------------------------------- | -| `--unpin` | Remove the `.node-version` file | -| `--no-install` | Skip pre-downloading the pinned version | -| `--force` | Overwrite existing `.node-version` without confirmation | +| Flag | Description | +| -------------------------------------- | -------------------------------------------------------------------------------- | +| `--unpin` | Remove the pin from its current source (`.node-version` or `devEngines.runtime`) | +| `--no-install` | Skip pre-downloading the pinned version | +| `--force` | Overwrite an existing pin without confirmation | +| `--target ` | Explicitly choose the write target (overrides the default selection) | ### Pre-download Behavior @@ -1497,6 +1512,13 @@ $ vp env pin 22.13.0 --force ✓ Pinned Node.js version to 22.13.0 ``` +When the target is already pinned to the same version, the command no-ops (with or without `--force`): + +```bash +$ vp env pin 22.13.0 +Already pinned to 22.13.0 +``` + ### Error Handling ```bash diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 6bf64ea0db..b439f939c3 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 `.node-version`, `engines.node`, and `devEngines.runtime`** +- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `.node-version`, `devEngines.runtime`, and `engines.node`** - Managing multiple runtime versions simultaneously - Providing a version manager CLI (like nvm/fnm) - Supporting custom/unofficial Node.js builds @@ -155,7 +155,7 @@ pub async fn download_runtime( ) -> Result; /// Download runtime based on project's version configuration -/// Reads from .node-version, engines.node, or devEngines.runtime (in priority order) +/// Reads from .node-version, devEngines.runtime, or engines.node (in priority order) /// Resolves semver ranges, downloads the matching version pub async fn download_runtime_for_project( project_path: &AbsolutePath, @@ -202,7 +202,7 @@ println!("Node.js installed at: {}", runtime.get_binary_path()); println!("Version: {}", runtime.version()); // "22.13.1" ``` -**Project-based download (reads from .node-version, engines.node, or devEngines.runtime):** +**Project-based download (reads from .node-version, devEngines.runtime, or engines.node):** ```rust use vite_js_runtime::download_runtime_for_project; @@ -210,7 +210,7 @@ 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 .node-version > engines.node > devEngines.runtime +// Version is resolved from .node-version > devEngines.runtime > engines.node ``` ## Cache Directory Structure @@ -271,8 +271,10 @@ The `download_runtime_for_project` function reads Node.js version from multiple | 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 | +| 2 | `devEngines.runtime` | `package.json` | `{"name":"node","version":"^24.4.0"}` | npm, pnpm | +| 3 (lowest) | `engines.node` | `package.json` | `">=20.0.0"` | Vercel, npm | + +`devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. See [RFC: devEngines Support](./dev-engines.md). ### `.node-version` File Format diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md index b510d06e8b..db14eacfee 100644 --- a/rfcs/migration-command.md +++ b/rfcs/migration-command.md @@ -227,10 +227,18 @@ Wrote agent instructions to AGENTS.md "@vitejs/plugin-react": "^4.2.0", "vite-plus": "catalog:" }, - "packageManager": "pnpm@" + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } } ``` +> Projects that already declare a top-level `packageManager` field keep that field updated instead (compatibility-first rule, see [RFC: devEngines Support](./dev-engines.md)). + **After (pnpm, no existing `pnpm` config) -- `pnpm-workspace.yaml`:** ```yaml diff --git a/rfcs/package-manager-detection.md b/rfcs/package-manager-detection.md index 5035db1e1a..376c88b059 100644 --- a/rfcs/package-manager-detection.md +++ b/rfcs/package-manager-detection.md @@ -33,9 +33,35 @@ The highest-priority signal. If the root `package.json` contains a `packageManag The explicit field also controls matching package-manager shims, including aliases generated for that manager. If a project declares `packageManager: "npm@11.14.0"`, the `npm` and `npx` shims run npm 11.14.0. Other aliases follow the same rule: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. If the project declares `pnpm`, `yarn`, or `bun`, invoking `npm` still runs npm; Vite+ never translates one package-manager shim command into another. -### Priority 2: Lockfiles +When `devEngines.packageManager` is also declared, the `packageManager` field still drives selection, but Vite+ warns when the field's name or version does not satisfy the devEngines constraint (this warning becomes a hard error in a future release; npm already errors in this situation). See [RFC: devEngines Support](./dev-engines.md). -If no `packageManager` field is found, Vite+ checks for lockfiles in the workspace root. Checked in this order: +### Priority 2: `devEngines.packageManager` field in `package.json` + +If there is no `packageManager` field, Vite+ checks `devEngines.packageManager`, following the [devEngines spec](https://github.com/openjs-foundation/package-metadata-interoperability-working-group/blob/main/devengines-field-proposal.md): + +```json +{ + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "^11.0.0", + "onFail": "download" + } + } +} +``` + +- Accepts a single object or an array of objects; entries are evaluated in order and the first entry with a supported `name` wins. +- `name` must be one of `pnpm`, `yarn`, `npm`, `bun`. Unsupported names are skipped in array form. When no entry names a supported package manager, the effective `onFail` of the last entry decides: `ignore`/`warn` continue down the detection chain, `error`/`download` fail with a clear message. +- `version` may be exact, a semver range, or absent (any version satisfies). Ranges resolve to an already-downloaded satisfying version when possible, otherwise to the latest satisfying version from the npm registry (fetched as the abbreviated metadata document). Prereleases are excluded unless the range itself contains a prerelease marker and no stable version satisfies it. +- A range source is never frozen into an exact `packageManager` field; the range stays the source of truth. +- `onFail` is otherwise parsed and preserved but not yet acted on: a selected (supported) entry whose version cannot be resolved or downloaded surfaces an error rather than falling back. See the RFC's [Deferred / Future Work](./dev-engines.md#deferred--future-work). + +See [RFC: devEngines Support](./dev-engines.md) for the full semantics (conflict handling, doctor checks, and the deferred `onFail` matrix). + +### Priority 3: Lockfiles + +If neither `packageManager` nor `devEngines.packageManager` is found, Vite+ checks for lockfiles in the workspace root. Checked in this order: | File | Detected PM | Notes | | --------------------- | ----------- | -------------------------------- | @@ -49,7 +75,7 @@ If no `packageManager` field is found, Vite+ checks for lockfiles in the workspa When detected from lockfiles, version is set to `"latest"` (resolved during download). -### Priority 3: Configuration files +### Priority 4: Configuration files Lower-priority config files that indicate a package manager: @@ -60,11 +86,11 @@ Lower-priority config files that indicate a package manager: | `bunfig.toml` | bun | [Bun configuration](https://bun.sh/docs/pm) | | `yarn.config.cjs` | yarn | Yarn Berry (v2+) configuration | -### Priority 4: Explicit default +### Priority 5: Explicit default If a caller provides a default package manager type (used internally by some code paths), that default is used with version `"latest"`. -### Priority 5: Interactive selection +### Priority 6: Interactive selection If no signals are detected and no default is provided, the behavior depends on the environment: @@ -119,7 +145,7 @@ vp create vite:monorepo --no-interactive --package-manager bun **Resolution priority for `vp create`**: -1. Detected workspace `packageManager` field (existing monorepo takes precedence) +1. Detected workspace package manager (`packageManager` field or `devEngines.packageManager`; existing monorepo takes precedence) 2. `--package-manager` CLI flag 3. Interactive prompt / auto-default (pnpm) @@ -127,19 +153,29 @@ This ensures monorepo consistency: if you run `vp create` inside an existing wor ## Auto-Update Behavior -After detection and download, Vite+ automatically writes the resolved package manager version to the `packageManager` field in `package.json`. This ensures: +After detection and download, Vite+ writes the resolved version back to `package.json` so future runs are deterministic: + +- Detection from the `packageManager` field or an exact `devEngines.packageManager` version: already exact, no write needed. +- Detection from a `devEngines.packageManager` range: no write; the range is the user's source of truth and is never frozen into an exact pin. +- Detection from lockfiles, config files, or interactive selection: the exact resolved version is written to `devEngines.packageManager` with `onFail: "download"`. -- Future runs use the exact version (Priority 1 match) +The write preserves existing entries Vite+ does not act on (e.g. another package manager declared with `onFail: "ignore"`): the resolved entry is appended to an existing array, an existing single entry is converted to array form with the original kept first, and a single entry is only written when the field is absent or malformed. + +This ensures: + +- Future runs use a deterministic version (Priority 1 or 2 match) - Team members get consistent versions - CI environments use deterministic versions ## Version Resolution -| Detection method | Version used | -| ------------------------- | ---------------------------------------------------------------- | -| `packageManager` field | Exact version from field (e.g., `10.19.0`) | -| Lockfile/config detection | `"latest"` — resolved to latest stable version from npm registry | -| Interactive selection | `"latest"` — resolved to latest stable version from npm registry | +| Detection method | Version used | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `packageManager` field | Exact version from field (e.g., `10.19.0`) | +| `devEngines.packageManager` (exact version) | Exact version from field | +| `devEngines.packageManager` (range or absent) | Highest already-downloaded satisfying version, otherwise latest satisfying version from the npm registry | +| Lockfile/config detection | `"latest"`: resolved to latest stable version from npm registry | +| Interactive selection | `"latest"`: resolved to latest stable version from npm registry | **Special cases**: @@ -163,12 +199,12 @@ The package manager type and monorepo status together drive: ### Per package manager -| Package Manager | Lockfiles | Config Files | Field | -| --------------- | ----------------------- | ------------------------------------------------------ | ---------------- | -| pnpm | `pnpm-lock.yaml` | `pnpm-workspace.yaml`, `.pnpmfile.cjs`, `pnpmfile.cjs` | `packageManager` | -| yarn | `yarn.lock` | `.yarnrc.yml`, `.yarnrc`, `yarn.config.cjs` | `packageManager` | -| npm | `package-lock.json` | — | `packageManager` | -| bun | `bun.lock`, `bun.lockb` | `bunfig.toml` | `packageManager` | +| Package Manager | Lockfiles | Config Files | Fields | +| --------------- | ----------------------- | ------------------------------------------------------ | --------------------------------------------- | +| pnpm | `pnpm-lock.yaml` | `pnpm-workspace.yaml`, `.pnpmfile.cjs`, `pnpmfile.cjs` | `packageManager`, `devEngines.packageManager` | +| yarn | `yarn.lock` | `.yarnrc.yml`, `.yarnrc`, `yarn.config.cjs` | `packageManager`, `devEngines.packageManager` | +| npm | `package-lock.json` | — | `packageManager`, `devEngines.packageManager` | +| bun | `bun.lock`, `bun.lockb` | `bunfig.toml` | `packageManager`, `devEngines.packageManager` | ### Cache invalidation (fingerprint ignores) @@ -203,23 +239,6 @@ Each package manager has specific files that trigger cache invalidation when cha ## Future Enhancements -### `devEngines.packageManager` field - -Support the [Node.js `devEngines` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) for package manager constraints: - -```json -{ - "devEngines": { - "packageManager": { - "name": "pnpm", - "version": ">=10.0.0" - } - } -} -``` - -This would be checked between Priority 1 (`packageManager` field) and Priority 2 (lockfiles). It specifies a constraint rather than an exact version, so it would be combined with other signals. - ### Multiple lockfile conflict resolution Currently, if multiple lockfiles exist (e.g., both `pnpm-lock.yaml` and `package-lock.json`), the first one found in priority order wins silently. A future enhancement could warn about conflicting lockfiles and suggest cleanup.