Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/vite_global_cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,14 @@ pub enum Error {

#[error("{0}")]
Setup(#[from] vite_setup::error::Error),

#[error(
"Node.js {version} is incompatible with Vite+ CLI.\nRequired by Vite+: {requirement}{version_source}\n\n{help}"
)]
NodeVersionIncompatible {
version: String,
requirement: String,
version_source: String,
help: String,
},
}
148 changes: 139 additions & 9 deletions crates/vite_global_cli/src/js_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@

use std::process::{ExitStatus, Output};

use node_semver::{Range, Version};
use tokio::process::Command;
use vite_js_runtime::{
JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, is_valid_version,
read_package_json, resolve_node_version,
};
use vite_path::{AbsolutePath, AbsolutePathBuf};
use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend};
use vite_shared::{
PrependOptions, PrependResult,
env_vars::{self, VP_NODE_VERSION},
format_path_with_prepend,
};

use crate::{
commands::env::config::{self, ShimMode},
commands::env::config::{self, SESSION_VERSION_FILE, ShimMode},
error::Error,
shim,
};
Expand Down Expand Up @@ -112,6 +117,16 @@ impl JsExecutor {
cmd
}

/// Read the `engines.node` requirement from the CLI's own `package.json`.
///
/// Returns `None` when the file is missing, unreadable, or has no `engines.node`.
async fn get_cli_engines_requirement(&self) -> Option<String> {
let cli_dir = self.get_cli_package_dir().ok()?;
let pkg_path = cli_dir.join("package.json");
let pkg = read_package_json(&pkg_path).await.ok()??;
pkg.engines?.node.map(|s| s.to_string())
}

/// Get the CLI's package.json directory (parent of `scripts_dir`).
///
/// This is used for resolving the CLI's default Node.js version
Expand Down Expand Up @@ -140,7 +155,7 @@ impl JsExecutor {

let cli_dir = self.get_cli_package_dir()?;
tracing::debug!("Resolving CLI runtime from {:?}", cli_dir);
let runtime = download_runtime_for_project(&cli_dir).await?;
let runtime = download_runtime_for_project(&cli_dir).await?.0;
self.cli_runtime = Some(runtime);
}
Ok(self.cli_runtime.as_ref().unwrap())
Expand All @@ -167,14 +182,25 @@ impl JsExecutor {
}

// 1–2. Session overrides: env var (from `vp env use`), then file
let session_version = vite_shared::EnvConfig::get()
let session_version = if let Some(session_version) = vite_shared::EnvConfig::get()
.node_version
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let session_version = if session_version.is_some() {
session_version
.filter(|v| !v.is_empty())
{
self.check_runtime_compatibility(&session_version, Some(VP_NODE_VERSION), false)
.await?;
Some(session_version)
} else if let Some(session_version) = config::read_session_version().await {
// Read from file
self.check_runtime_compatibility(
&session_version,
Some(SESSION_VERSION_FILE),
false,
)
.await?;
Some(session_version)
} else {
config::read_session_version().await
None
};
if let Some(version) = session_version {
let runtime = download_runtime(JsRuntimeType::Node, &version).await?;
Expand All @@ -192,17 +218,82 @@ impl JsExecutor {
// At least one valid project source exists — delegate to
// download_runtime_for_project for cache-aware range resolution
// and intra-project fallback chain
download_runtime_for_project(project_path).await?
let (runtime, source) = download_runtime_for_project(project_path).await?;
self.check_runtime_compatibility(
&runtime.version,
source.map(|s| format!("{s}")).as_deref(),
true,
)
.await?;
runtime
} else {
// No valid project source — check user default from config, then LTS
let resolution = config::resolve_version(project_path).await?;
self.check_runtime_compatibility(
&resolution.version,
Some(&resolution.source),
false,
)
.await?;
download_runtime(JsRuntimeType::Node, &resolution.version).await?
};
self.project_runtime = Some(runtime);
}
Ok(self.project_runtime.as_ref().unwrap())
}

/// Check that a runtime's version satisfies vp's engine requirements.
///
/// Skips silently when:
/// - The runtime is a system install (version == `"system"`)
/// - The version or requirement strings cannot be parsed as semver
///
/// Returns [`Error::NodeVersionIncompatible`] when the version is parsable but
/// outside the required range.
async fn check_runtime_compatibility(
&self,
version: &str,
source: Option<&str>,
is_project_runtime: bool,
) -> Result<(), Error> {
let Some(requirement) = self.get_cli_engines_requirement().await else { return Ok(()) };

// System runtimes report "system" — we cannot inspect the actual version cheaply,
// and the user has explicitly opted in via `vp env off`.
if version == "system" {
return Ok(());
}

let normalized = version.strip_prefix('v').unwrap_or(version);
let Ok(version) = Version::parse(normalized) else {
return Ok(()); // unparsable version — skip silently
};
let Ok(range) = Range::parse(&requirement) else {
return Ok(()); // invalid range in package.json — skip silently
};

if !range.satisfies(&version) {
let version_source =
source.map(|s| format!("\nResolved from: {s}\n")).unwrap_or_default();

let help = (if is_project_runtime {
"Fix this project: vp env pin lts"
} else {
"Set a compatible version globally: vp env default lts"
})
.to_owned();
let help = format!("{help}\nTemporary override: vp env use lts");

return Err(Error::NodeVersionIncompatible {
version: version.to_string(),
requirement: requirement.to_string(),
version_source,
help,
});
}
Ok(())
}

/// Download a specific Node.js version.
///
/// This is used when we need a specific version regardless of
Expand Down Expand Up @@ -474,6 +565,45 @@ mod tests {
assert_eq!(cmd.as_std().get_program(), OsStr::new(expected_program));
}

/// Pin Node.js to 20.0.0
/// and any vp command should be blocked with a clear error instead of crashing
#[tokio::test]
async fn incompatible_node_version_should_be_blocked() {
use tempfile::TempDir;
use vite_shared::EnvConfig;

// Point scripts_dir at the real packages/cli/dist so that
// get_cli_engines_requirement() reads the actual engines.node from
// packages/cli/package.json. The dist/ directory need not exist — only
// its parent (packages/cli/) and the package.json within it are read.
let scripts_dir = AbsolutePathBuf::new(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../packages/cli/dist"),
)
.unwrap();

// Use any existing directory as project_path; the session override
// fires before any project-source lookup or network download.
let temp_dir = TempDir::new().unwrap();
let project_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();

// Simulate `.node-version: 20.0.0` / `vp env use 20.0.0` via a session override.
let _guard = EnvConfig::test_guard(EnvConfig {
node_version: Some("20.0.0".to_string()),
..EnvConfig::for_test()
});

let mut executor = JsExecutor::new(Some(scripts_dir));
let err = executor
.ensure_project_runtime(&project_path)
.await
.expect_err("Node.js 20.0.0 should be rejected as incompatible with vp requirements");

assert!(
matches!(&err, Error::NodeVersionIncompatible { version, .. } if version == "20.0.0"),
"expected NodeVersionIncompatible for 20.0.0, got: {err:?}"
);
}

#[tokio::test]
#[serial]
async fn test_delegate_to_local_cli_prints_node_version() {
Expand Down
Loading
Loading