Skip to content

Commit 4d9bc46

Browse files
liangmiQwQclaudefengmk2
authored
feat(js-executor): block vp commands when Node.js version is incompatible (#1360)
Closes #1358 This PR added check logic to block running / downloading node with unsupported node versions. The check logic is added in `ensure_project_runtime` fn to make sure all `vp` commands run locally node be checked, not only for Categories C cmds. While direct `node` won't be influenced. Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent bd0e949 commit 4d9bc46

File tree

20 files changed

+248
-49
lines changed

20 files changed

+248
-49
lines changed

crates/vite_global_cli/src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,14 @@ pub enum Error {
5454

5555
#[error("{0}")]
5656
Setup(#[from] vite_setup::error::Error),
57+
58+
#[error(
59+
"Node.js {version} is incompatible with Vite+ CLI.\nRequired by Vite+: {requirement}{version_source}\n\n{help}"
60+
)]
61+
NodeVersionIncompatible {
62+
version: String,
63+
requirement: String,
64+
version_source: String,
65+
help: String,
66+
},
5767
}

crates/vite_global_cli/src/js_executor.rs

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@
55
66
use std::process::{ExitStatus, Output};
77

8+
use node_semver::{Range, Version};
89
use tokio::process::Command;
910
use vite_js_runtime::{
1011
JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, is_valid_version,
1112
read_package_json, resolve_node_version,
1213
};
1314
use vite_path::{AbsolutePath, AbsolutePathBuf};
14-
use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend};
15+
use vite_shared::{
16+
PrependOptions, PrependResult,
17+
env_vars::{self, VP_NODE_VERSION},
18+
format_path_with_prepend,
19+
};
1520

1621
use crate::{
17-
commands::env::config::{self, ShimMode},
22+
commands::env::config::{self, SESSION_VERSION_FILE, ShimMode},
1823
error::Error,
1924
shim,
2025
};
@@ -112,6 +117,16 @@ impl JsExecutor {
112117
cmd
113118
}
114119

120+
/// Read the `engines.node` requirement from the CLI's own `package.json`.
121+
///
122+
/// Returns `None` when the file is missing, unreadable, or has no `engines.node`.
123+
async fn get_cli_engines_requirement(&self) -> Option<String> {
124+
let cli_dir = self.get_cli_package_dir().ok()?;
125+
let pkg_path = cli_dir.join("package.json");
126+
let pkg = read_package_json(&pkg_path).await.ok()??;
127+
pkg.engines?.node.map(|s| s.to_string())
128+
}
129+
115130
/// Get the CLI's package.json directory (parent of `scripts_dir`).
116131
///
117132
/// This is used for resolving the CLI's default Node.js version
@@ -140,7 +155,7 @@ impl JsExecutor {
140155

141156
let cli_dir = self.get_cli_package_dir()?;
142157
tracing::debug!("Resolving CLI runtime from {:?}", cli_dir);
143-
let runtime = download_runtime_for_project(&cli_dir).await?;
158+
let runtime = download_runtime_for_project(&cli_dir).await?.0;
144159
self.cli_runtime = Some(runtime);
145160
}
146161
Ok(self.cli_runtime.as_ref().unwrap())
@@ -167,14 +182,25 @@ impl JsExecutor {
167182
}
168183

169184
// 1–2. Session overrides: env var (from `vp env use`), then file
170-
let session_version = vite_shared::EnvConfig::get()
185+
let session_version = if let Some(session_version) = vite_shared::EnvConfig::get()
171186
.node_version
172187
.map(|v| v.trim().to_string())
173-
.filter(|v| !v.is_empty());
174-
let session_version = if session_version.is_some() {
175-
session_version
188+
.filter(|v| !v.is_empty())
189+
{
190+
self.check_runtime_compatibility(&session_version, Some(VP_NODE_VERSION), false)
191+
.await?;
192+
Some(session_version)
193+
} else if let Some(session_version) = config::read_session_version().await {
194+
// Read from file
195+
self.check_runtime_compatibility(
196+
&session_version,
197+
Some(SESSION_VERSION_FILE),
198+
false,
199+
)
200+
.await?;
201+
Some(session_version)
176202
} else {
177-
config::read_session_version().await
203+
None
178204
};
179205
if let Some(version) = session_version {
180206
let runtime = download_runtime(JsRuntimeType::Node, &version).await?;
@@ -192,17 +218,82 @@ impl JsExecutor {
192218
// At least one valid project source exists — delegate to
193219
// download_runtime_for_project for cache-aware range resolution
194220
// and intra-project fallback chain
195-
download_runtime_for_project(project_path).await?
221+
let (runtime, source) = download_runtime_for_project(project_path).await?;
222+
self.check_runtime_compatibility(
223+
&runtime.version,
224+
source.map(|s| format!("{s}")).as_deref(),
225+
true,
226+
)
227+
.await?;
228+
runtime
196229
} else {
197230
// No valid project source — check user default from config, then LTS
198231
let resolution = config::resolve_version(project_path).await?;
232+
self.check_runtime_compatibility(
233+
&resolution.version,
234+
Some(&resolution.source),
235+
false,
236+
)
237+
.await?;
199238
download_runtime(JsRuntimeType::Node, &resolution.version).await?
200239
};
201240
self.project_runtime = Some(runtime);
202241
}
203242
Ok(self.project_runtime.as_ref().unwrap())
204243
}
205244

245+
/// Check that a runtime's version satisfies vp's engine requirements.
246+
///
247+
/// Skips silently when:
248+
/// - The runtime is a system install (version == `"system"`)
249+
/// - The version or requirement strings cannot be parsed as semver
250+
///
251+
/// Returns [`Error::NodeVersionIncompatible`] when the version is parsable but
252+
/// outside the required range.
253+
async fn check_runtime_compatibility(
254+
&self,
255+
version: &str,
256+
source: Option<&str>,
257+
is_project_runtime: bool,
258+
) -> Result<(), Error> {
259+
let Some(requirement) = self.get_cli_engines_requirement().await else { return Ok(()) };
260+
261+
// System runtimes report "system" — we cannot inspect the actual version cheaply,
262+
// and the user has explicitly opted in via `vp env off`.
263+
if version == "system" {
264+
return Ok(());
265+
}
266+
267+
let normalized = version.strip_prefix('v').unwrap_or(version);
268+
let Ok(version) = Version::parse(normalized) else {
269+
return Ok(()); // unparsable version — skip silently
270+
};
271+
let Ok(range) = Range::parse(&requirement) else {
272+
return Ok(()); // invalid range in package.json — skip silently
273+
};
274+
275+
if !range.satisfies(&version) {
276+
let version_source =
277+
source.map(|s| format!("\nResolved from: {s}\n")).unwrap_or_default();
278+
279+
let help = (if is_project_runtime {
280+
"Fix this project: vp env pin lts"
281+
} else {
282+
"Set a compatible version globally: vp env default lts"
283+
})
284+
.to_owned();
285+
let help = format!("{help}\nTemporary override: vp env use lts");
286+
287+
return Err(Error::NodeVersionIncompatible {
288+
version: version.to_string(),
289+
requirement: requirement.to_string(),
290+
version_source,
291+
help,
292+
});
293+
}
294+
Ok(())
295+
}
296+
206297
/// Download a specific Node.js version.
207298
///
208299
/// This is used when we need a specific version regardless of
@@ -474,6 +565,45 @@ mod tests {
474565
assert_eq!(cmd.as_std().get_program(), OsStr::new(expected_program));
475566
}
476567

568+
/// Pin Node.js to 20.0.0
569+
/// and any vp command should be blocked with a clear error instead of crashing
570+
#[tokio::test]
571+
async fn incompatible_node_version_should_be_blocked() {
572+
use tempfile::TempDir;
573+
use vite_shared::EnvConfig;
574+
575+
// Point scripts_dir at the real packages/cli/dist so that
576+
// get_cli_engines_requirement() reads the actual engines.node from
577+
// packages/cli/package.json. The dist/ directory need not exist — only
578+
// its parent (packages/cli/) and the package.json within it are read.
579+
let scripts_dir = AbsolutePathBuf::new(
580+
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../packages/cli/dist"),
581+
)
582+
.unwrap();
583+
584+
// Use any existing directory as project_path; the session override
585+
// fires before any project-source lookup or network download.
586+
let temp_dir = TempDir::new().unwrap();
587+
let project_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
588+
589+
// Simulate `.node-version: 20.0.0` / `vp env use 20.0.0` via a session override.
590+
let _guard = EnvConfig::test_guard(EnvConfig {
591+
node_version: Some("20.0.0".to_string()),
592+
..EnvConfig::for_test()
593+
});
594+
595+
let mut executor = JsExecutor::new(Some(scripts_dir));
596+
let err = executor
597+
.ensure_project_runtime(&project_path)
598+
.await
599+
.expect_err("Node.js 20.0.0 should be rejected as incompatible with vp requirements");
600+
601+
assert!(
602+
matches!(&err, Error::NodeVersionIncompatible { version, .. } if version == "20.0.0"),
603+
"expected NodeVersionIncompatible for 20.0.0, got: {err:?}"
604+
);
605+
}
606+
477607
#[tokio::test]
478608
#[serial]
479609
async fn test_delegate_to_local_cli_prints_node_version() {

0 commit comments

Comments
 (0)