66use std:: process:: ExitStatus ;
77
88use tokio:: process:: Command ;
9- use vite_js_runtime:: { JsRuntime , JsRuntimeType , download_runtime, download_runtime_for_project} ;
9+ use vite_js_runtime:: {
10+ JsRuntime , JsRuntimeType , download_runtime, download_runtime_for_project, is_valid_version,
11+ read_package_json, resolve_node_version,
12+ } ;
1013use vite_path:: { AbsolutePath , AbsolutePathBuf } ;
1114use vite_shared:: { PrependOptions , PrependResult , env_vars, format_path_with_prepend} ;
1215
13- use crate :: error:: Error ;
16+ use crate :: { commands :: env :: config , error:: Error } ;
1417
1518/// JavaScript executor using managed Node.js runtime.
1619///
@@ -134,15 +137,52 @@ impl JsExecutor {
134137
135138 /// Ensure the project runtime is downloaded and cached.
136139 ///
137- /// Uses the project's package.json `devEngines.runtime` configuration
138- /// to determine which Node.js version to use.
140+ /// Resolution order:
141+ /// 1. Session override (env var from `vp env use`)
142+ /// 2. Session override (file from `vp env use`)
143+ /// 3. Project sources (.node-version, engines.node, devEngines.runtime) —
144+ /// delegates to `download_runtime_for_project()` for cache-aware resolution
145+ /// 4. User default from config.json
146+ /// 5. Latest LTS
139147 pub async fn ensure_project_runtime (
140148 & mut self ,
141149 project_path : & AbsolutePath ,
142150 ) -> Result < & JsRuntime , Error > {
143151 if self . project_runtime . is_none ( ) {
144152 tracing:: debug!( "Resolving project runtime from {:?}" , project_path) ;
145- let runtime = download_runtime_for_project ( project_path) . await ?;
153+
154+ // 1–2. Session overrides: env var (from `vp env use`), then file
155+ let session_version = vite_shared:: EnvConfig :: get ( )
156+ . node_version
157+ . map ( |v| v. trim ( ) . to_string ( ) )
158+ . filter ( |v| !v. is_empty ( ) ) ;
159+ let session_version = if session_version. is_some ( ) {
160+ session_version
161+ } else {
162+ config:: read_session_version ( ) . await
163+ } ;
164+ if let Some ( version) = session_version {
165+ let runtime = download_runtime ( JsRuntimeType :: Node , & version) . await ?;
166+ return Ok ( self . project_runtime . insert ( runtime) ) ;
167+ }
168+
169+ // 3. Check if project has any *valid* version source.
170+ // resolve_node_version returns Some for any non-empty value,
171+ // even invalid ones. We must validate before routing to
172+ // download_runtime_for_project, which falls to LTS on all-invalid
173+ // and would skip the user's configured default.
174+ let has_valid_project_source = has_valid_version_source ( project_path) . await ?;
175+
176+ let runtime = if has_valid_project_source {
177+ // At least one valid project source exists — delegate to
178+ // download_runtime_for_project for cache-aware range resolution
179+ // and intra-project fallback chain
180+ download_runtime_for_project ( project_path) . await ?
181+ } else {
182+ // No valid project source — check user default from config, then LTS
183+ let resolution = config:: resolve_version ( project_path) . await ?;
184+ download_runtime ( JsRuntimeType :: Node , & resolution. version ) . await ?
185+ } ;
146186 self . project_runtime = Some ( runtime) ;
147187 }
148188 Ok ( self . project_runtime . as_ref ( ) . unwrap ( ) )
@@ -163,8 +203,7 @@ impl JsExecutor {
163203 /// If found, runs the local `dist/bin.js` directly. Otherwise, falls back
164204 /// to the global installation's `dist/bin.js`.
165205 ///
166- /// Uses the project's runtime (from its `devEngines.runtime` configuration).
167- /// This may write a `.node-version` file if the project has no version source.
206+ /// Uses the project's runtime resolved via `config::resolve_version()`.
168207 /// For side-effect-free commands like `--version`, use [`delegate_with_cli_runtime`] instead.
169208 ///
170209 /// # Arguments
@@ -252,6 +291,48 @@ impl JsExecutor {
252291 }
253292}
254293
294+ /// Check whether a project directory has at least one valid version source.
295+ ///
296+ /// Uses `is_valid_version` (no warning side effects) to avoid duplicate
297+ /// warnings when `download_runtime_for_project` or `config::resolve_version`
298+ /// later call `normalize_version` on the same values.
299+ ///
300+ /// Returns `false` when all sources are missing or invalid, so the caller
301+ /// can fall through to the user's configured default instead of LTS.
302+ async fn has_valid_version_source (
303+ project_path : & AbsolutePath ,
304+ ) -> Result < bool , vite_js_runtime:: Error > {
305+ let resolution = resolve_node_version ( project_path, true ) . await ?;
306+ let Some ( ref r) = resolution else {
307+ return Ok ( false ) ;
308+ } ;
309+
310+ // Primary source is a valid version?
311+ if is_valid_version ( & r. version ) {
312+ return Ok ( true ) ;
313+ }
314+
315+ // Primary source invalid — check package.json for valid fallbacks
316+ let pkg_path = project_path. join ( "package.json" ) ;
317+ let Ok ( Some ( pkg) ) = read_package_json ( & pkg_path) . await else {
318+ return Ok ( false ) ;
319+ } ;
320+
321+ let engines_valid =
322+ pkg. engines . as_ref ( ) . and_then ( |e| e. node . as_ref ( ) ) . is_some_and ( |v| is_valid_version ( v) ) ;
323+
324+ let dev_engines_valid = !engines_valid
325+ && pkg
326+ . dev_engines
327+ . as_ref ( )
328+ . and_then ( |de| de. runtime . as_ref ( ) )
329+ . and_then ( |rt| rt. find_by_name ( "node" ) )
330+ . filter ( |r| !r. version . is_empty ( ) )
331+ . is_some_and ( |r| is_valid_version ( & r. version ) ) ;
332+
333+ Ok ( engines_valid || dev_engines_valid)
334+ }
335+
255336#[ cfg( test) ]
256337mod tests {
257338 use serial_test:: serial;
0 commit comments