55
66use std:: process:: { ExitStatus , Output } ;
77
8+ use node_semver:: { Range , Version } ;
89use tokio:: process:: Command ;
910use vite_js_runtime:: {
1011 JsRuntime , JsRuntimeType , download_runtime, download_runtime_for_project, is_valid_version,
1112 read_package_json, resolve_node_version,
1213} ;
1314use 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
1621use 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 ! ( "\n Resolved 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}\n Temporary 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