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