From 99cf1832d48004b3f4397f80b28cf012912a15e6 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 16 Oct 2026 22:44:43 +0800 Subject: [PATCH 1/5] perf(setup): reuse compatible project Node runtimes --- .../vp_global_cli/src/commands/upgrade/mod.rs | 2 +- crates/vp_global_cli/src/self_setup.rs | 11 +- .../vp_global_cli/src/self_setup/runtime.rs | 101 ++++++++++++++++ crates/vp_installer/src/legacy.rs | 3 +- crates/vp_setup/Cargo.toml | 1 + crates/vp_setup/src/install.rs | 113 ++++++++++++++++-- crates/vp_shared/src/env_config.rs | 7 ++ crates/vp_shared/src/env_vars.rs | 5 + 8 files changed, 230 insertions(+), 13 deletions(-) create mode 100644 crates/vp_global_cli/src/self_setup/runtime.rs diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 5ac9a46e37..f2b2540090 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -231,7 +231,7 @@ async fn install_platform_and_main( install::generate_wrapper_package_json(version_dir, new_version).await?; // Install production dependencies (pnpm installs vite-plus + all transitive deps) - install::install_production_deps(version_dir, registry, !silent).await?; + install::install_production_deps(version_dir, registry, !silent, None).await?; // Save previous version for rollback let previous_version = install::save_previous_version(install_dir).await?; diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index c73293189c..b37096153a 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -1,6 +1,7 @@ //! First-start installation followed by command execution through the deployed binary. mod external; +mod runtime; mod shell; use std::{path::Path, process::ExitCode}; @@ -241,7 +242,15 @@ async fn run(source: &Path, bundled: bool) -> Result { output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; if !skip_deps { - install::install_production_deps(&version_dir, registry, true).await?; + let node_version = runtime::resolve(&vt_path::current_dir()?).await?; + let preferred = runtime::supports_cli(&node_version).then_some(node_version.as_str()); + if preferred.is_none() { + output::info(&format!( + "Project Node.js {node_version} does not support this Vite+ release; using a separate installation runtime." + )); + } + install::install_production_deps(&version_dir, registry, true, preferred).await?; + runtime::write_resolution(&node_version).await?; } } #[cfg(windows)] diff --git a/crates/vp_global_cli/src/self_setup/runtime.rs b/crates/vp_global_cli/src/self_setup/runtime.rs new file mode 100644 index 0000000000..64778fa45c --- /dev/null +++ b/crates/vp_global_cli/src/self_setup/runtime.rs @@ -0,0 +1,101 @@ +//! Share a compatible project runtime with first-start dependency installation. + +use js_semver::{Range, Version}; +use vp_js_runtime::NodeProvider; +use vp_shared::EnvConfig; +use vt_path::AbsolutePath; + +use crate::{commands::env::config, error::Error}; + +pub(super) async fn resolve(cwd: &AbsolutePath) -> Result { + let env = EnvConfig::get(); + let selector = match env.setup_node_version.as_deref().filter(|value| !value.trim().is_empty()) + { + Some(selector) => selector.trim().to_string(), + None => config::resolve_version(cwd).await?.version, + }; + // Session overrides and explicit CI inputs can still contain a selector. + // File-based resolution already returns an exact version, which is reused. + config::resolve_version_alias(&selector, &NodeProvider::new()).await +} + +pub(super) fn supports_cli(version: &str) -> bool { + // This binary installs its own release. Read that release's requirement + // from the same package manifest used to publish it. + let package: serde_json::Value = + serde_json::from_str(include_str!("../../../../packages/cli/package.json")) + .expect("CLI package.json must be valid JSON"); + let range = package["engines"]["node"].as_str().expect("CLI must declare engines.node"); + Version::parse(version).is_ok_and(|version| { + Range::parse(range).expect("CLI Node range must be valid").satisfies(&version) + }) +} + +pub(super) async fn write_resolution(version: &str) -> Result<(), Error> { + if let Some(file) = + EnvConfig::get().setup_node_version_file.as_deref().filter(|file| !file.is_empty()) + { + tokio::fs::write(file, version).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use vp_shared::env_vars; + + use super::*; + + async fn with_project(selector: &str, project_version: &str) -> String { + let root = tempfile::tempdir().unwrap(); + let root = vt_path::AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let project = root.join("project"); + let nested = project.join("packages/app"); + tokio::fs::create_dir_all(&nested).await.unwrap(); + tokio::fs::write(project.join(".node-version"), project_version).await.unwrap(); + tokio::fs::write(project.join(".nvmrc"), "24.11.0").await.unwrap(); + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, root.as_path().to_str().unwrap()), + (env_vars::VP_SETUP_NODE_VERSION, selector), + (env_vars::VP_NODE_VERSION, ""), + ], + |_| async move { resolve(&nested).await.unwrap() }, + ) + .await + } + + #[tokio::test] + async fn explicit_selector_precedes_project() { + assert_eq!(with_project("v22.23.1", "26.0.0").await, "22.23.1"); + } + + #[tokio::test] + async fn project_selection_uses_existing_precedence_and_parent_lookup() { + assert_eq!(with_project("", "22.23.1").await, "22.23.1"); + } + + #[test] + fn accepts_supported_project_versions() { + assert!(supports_cli("22.23.1")); + assert!(supports_cli("24.11.0")); + assert!(supports_cli("26.0.0")); + assert!(!supports_cli("20.18.0")); + assert!(!supports_cli("22.17.0")); + assert!(!supports_cli("24.10.0")); + assert!(!supports_cli("invalid")); + } + + #[tokio::test] + async fn returns_the_project_version_even_when_bootstrap_needs_a_fallback() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("selected-node"); + let version = with_project("20.18.0", "26.0.0").await; + assert!(!supports_cli(&version)); + EnvConfig::with_vars_async([(env_vars::VP_SETUP_NODE_VERSION_FILE, &file)], |_| async { + write_resolution(&version).await.unwrap(); + }) + .await; + assert_eq!(tokio::fs::read_to_string(file).await.unwrap(), "20.18.0"); + } +} diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs index 42bf7bf33e..215a1b8f98 100644 --- a/crates/vp_installer/src/legacy.rs +++ b/crates/vp_installer/src/legacy.rs @@ -137,7 +137,8 @@ async fn install_new_version( if !opts.quiet { print_info("installing dependencies (this may take a moment)..."); } - install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet).await?; + install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet, None) + .await?; let previous_version = if has_previous { install::save_previous_version(install_dir).await? } else { None }; diff --git a/crates/vp_setup/Cargo.toml b/crates/vp_setup/Cargo.toml index 7ad463f2d9..6ad65eb3d0 100644 --- a/crates/vp_setup/Cargo.toml +++ b/crates/vp_setup/Cargo.toml @@ -30,6 +30,7 @@ junction = { workspace = true } [dev-dependencies] httpmock = { workspace = true } tempfile = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } [lib] doctest = false diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 0e2cf48581..f512ab3e13 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -169,7 +169,7 @@ pub async fn write_upgrade_log( } } -/// Install production dependencies with managed Node.js LTS and pinned pnpm. +/// Install production dependencies with a compatible managed Node.js and pinned pnpm. /// /// Spawns: `node /bin/pnpm.cjs install --ignore-workspace [--registry ]` /// with `CI=true`. On failure, writes stdout+stderr to the parent directory's `upgrade.log`. @@ -177,6 +177,7 @@ pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, show_progress: bool, + preferred_node_version: Option<&str>, ) -> Result<(), Error> { tracing::debug!("Running pnpm install in {}", version_dir.as_path().display()); @@ -192,7 +193,7 @@ pub async fn install_production_deps( let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( show_progress.then_some("Preparing Node.js and pnpm..."), - prepare_install_runtime(), + prepare_install_runtime(preferred_node_version), ) .await?; let output = vp_shared::progress::with_spinner( @@ -218,14 +219,9 @@ pub async fn install_production_deps( Ok(()) } -async fn prepare_install_runtime() -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { - let node_version = NodeProvider::new().resolve_latest_version().await.map_err(|error| { - Error::Setup(format!("Failed to resolve the latest Node.js LTS version: {error}").into()) - })?; - let node_runtime = - download_runtime(JsRuntimeType::Node, &node_version).await.map_err(|error| { - Error::Setup(format!("Failed to install Node.js {node_version}: {error}").into()) - })?; +async fn prepare_install_runtime( + preferred_node_version: Option<&str>, +) -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { let (pnpm_dir, _, _) = download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None) .await @@ -240,9 +236,42 @@ async fn prepare_install_runtime() -> Result<(vp_js_runtime::JsRuntime, Absolute format!("pnpm entry not found at {}", pnpm_entry.as_path().display()).into(), )); } + let package: serde_json::Value = serde_json::from_slice( + &tokio::fs::read(pnpm_dir.join("package.json")).await?, + ) + .map_err(|error| { + Error::Setup(format!("Failed to read pnpm's Node.js requirement: {error}").into()) + })?; + let node_version = + match preferred_node_version.filter(|version| supports_pnpm_node(&package, version)) { + Some(version) => version.to_string(), + None => NodeProvider::new() + .resolve_latest_version() + .await + .map_err(|error| { + Error::Setup( + format!("Failed to resolve the latest Node.js LTS version: {error}").into(), + ) + })? + .to_string(), + }; + let node_runtime = + download_runtime(JsRuntimeType::Node, &node_version).await.map_err(|error| { + Error::Setup(format!("Failed to install Node.js {node_version}: {error}").into()) + })?; Ok((node_runtime, pnpm_entry)) } +fn supports_pnpm_node(package: &serde_json::Value, version: &str) -> bool { + let Some(range) = package["engines"]["node"].as_str() else { return false }; + let (Ok(range), Ok(version)) = + (js_semver::Range::parse(range), js_semver::Version::parse(version)) + else { + return false; + }; + range.satisfies(&version) +} + async fn run_pnpm_install( version_dir: &AbsolutePath, node_runtime: &vp_js_runtime::JsRuntime, @@ -567,6 +596,70 @@ pub async fn create_env_files(install_dir: &AbsolutePath) -> Result<(), Error> { mod tests { use super::*; + #[tokio::test] + async fn bootstrap_reuses_project_node_and_only_resolves_lts_for_incompatible_pnpm() { + use vp_js_runtime::{JsRuntimeProvider, Platform}; + use vp_shared::{EnvConfig, env_vars}; + + let server = httpmock::MockServer::start_async().await; + let index = server.mock(|when, then| { + when.path("/index.json"); + then.status(200).json_body(serde_json::json!([ + {"version":"v24.11.0", "lts":"Krypton"}, + {"version":"v22.23.1", "lts":"Jod"} + ])); + }); + let root = tempfile::tempdir().unwrap(); + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, root.path().to_str().unwrap()), + (env_vars::VP_NODE_DIST_MIRROR, server.base_url().as_str()), + ], + |env| async move { + let pnpm = env + .dirs + .data + .join("package_manager/pnpm") + .join(PINNED_PNPM_VERSION) + .join("pnpm"); + tokio::fs::create_dir_all(pnpm.join("bin")).await.unwrap(); + for name in ["pnpm", "pnpm.cmd", "pnpm.ps1", "pnpm.cjs"] { + tokio::fs::write(pnpm.join("bin").join(name), "").await.unwrap(); + } + let manifest = pnpm.join("package.json"); + tokio::fs::write(&manifest, r#"{"engines":{"node":">=18.12"}}"#).await.unwrap(); + for version in ["22.23.1", "24.11.0"] { + let binary = env + .dirs + .data + .join("js_runtime/node") + .join(version) + .join(NodeProvider::new().binary_relative_path(Platform::current())); + tokio::fs::create_dir_all(binary.parent().unwrap()).await.unwrap(); + tokio::fs::write(binary, "").await.unwrap(); + } + + let (runtime, entry) = prepare_install_runtime(Some("22.23.1")).await.unwrap(); + assert_eq!(runtime.version(), "22.23.1"); + assert_eq!(entry, pnpm.join("bin/pnpm.cjs")); + index.assert_calls(0); + + tokio::fs::write(manifest, r#"{"engines":{"node":">=24"}}"#).await.unwrap(); + let (runtime, _) = prepare_install_runtime(Some("22.23.1")).await.unwrap(); + assert_eq!(runtime.version(), "24.11.0"); + index.assert_calls(1); + }, + ) + .await; + } + + #[test] + fn pnpm_node_compatibility_fails_closed() { + assert!(!supports_pnpm_node(&serde_json::json!({}), "22.23.1")); + assert!(!supports_pnpm_node(&serde_json::json!({"engines":{"node":"invalid"}}), "22.23.1")); + assert!(!supports_pnpm_node(&serde_json::json!({"engines":{"node":">=18.12"}}), "invalid")); + } + #[tokio::test] async fn invalidate_only_the_target_versions_setup() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 103b26c041..7621c889e6 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -157,6 +157,11 @@ pub struct EnvConfig { /// Env: `VP_NODE_VERSION` pub node_version: Option, + /// Bootstrap-only Node selector supplied by a CI integration. + pub setup_node_version: Option, + /// Optional file for returning the resolved project Node version. + pub setup_node_version_file: Option, + /// Override package manager and version. /// /// Env: `VP_PACKAGE_MANAGER` @@ -213,6 +218,8 @@ impl EnvConfig { is_ci: std::env::var("CI").is_ok(), env_use_eval_enable: std::env::var(env_vars::VP_ENV_USE_EVAL_ENABLE).is_ok(), node_version: std::env::var(env_vars::VP_NODE_VERSION).ok(), + setup_node_version: std::env::var(env_vars::VP_SETUP_NODE_VERSION).ok(), + setup_node_version_file: std::env::var(env_vars::VP_SETUP_NODE_VERSION_FILE).ok(), package_manager: std::env::var(env_vars::VP_PACKAGE_MANAGER).ok(), npm_version: std::env::var(env_vars::VP_NPM_VERSION).ok(), pnpm_version: std::env::var(env_vars::VP_PNPM_VERSION).ok(), diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 3b3a1fd908..68d5e44114 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -85,6 +85,11 @@ pub const VP_NODE_SKIP_SIGNATURE_VERIFY: &str = "VP_NODE_SKIP_SIGNATURE_VERIFY"; /// Override Node.js version (takes highest priority in version resolution). pub const VP_NODE_VERSION: &str = "VP_NODE_VERSION"; +/// Preferred Node.js selector for first-start installation only. +pub const VP_SETUP_NODE_VERSION: &str = "VP_SETUP_NODE_VERSION"; +/// File used to return the exact project Node.js version to a CI installer. +pub const VP_SETUP_NODE_VERSION_FILE: &str = "VP_SETUP_NODE_VERSION_FILE"; + /// Override package manager and version for vp commands (for example, `pnpm@10.18.0`). /// Direct package-manager shims use their own version overrides instead. pub const VP_PACKAGE_MANAGER: &str = "VP_PACKAGE_MANAGER"; From 65f9bc56f1f2d87e138013c5bcde9e7306d8eeb8 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 16 Oct 2026 23:08:17 +0800 Subject: [PATCH 2/5] fix(setup): keep LTS unless a bootstrap Node override is set --- crates/vp_global_cli/src/self_setup.rs | 13 +++-- .../vp_global_cli/src/self_setup/runtime.rs | 50 +++++++++---------- crates/vp_shared/src/env_vars.rs | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index b37096153a..223d1b6f46 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -242,15 +242,18 @@ async fn run(source: &Path, bundled: bool) -> Result { output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; if !skip_deps { - let node_version = runtime::resolve(&vt_path::current_dir()?).await?; - let preferred = runtime::supports_cli(&node_version).then_some(node_version.as_str()); - if preferred.is_none() { + let node_version = runtime::resolve().await?; + let preferred = + node_version.as_deref().filter(|version| runtime::supports_cli(version)); + if let Some(version) = node_version.as_deref().filter(|_| preferred.is_none()) { output::info(&format!( - "Project Node.js {node_version} does not support this Vite+ release; using a separate installation runtime." + "Requested Node.js {version} does not support this Vite+ release; using a separate installation runtime." )); } install::install_production_deps(&version_dir, registry, true, preferred).await?; - runtime::write_resolution(&node_version).await?; + if let Some(version) = node_version { + runtime::write_resolution(&version).await?; + } } } #[cfg(windows)] diff --git a/crates/vp_global_cli/src/self_setup/runtime.rs b/crates/vp_global_cli/src/self_setup/runtime.rs index 64778fa45c..320cdae090 100644 --- a/crates/vp_global_cli/src/self_setup/runtime.rs +++ b/crates/vp_global_cli/src/self_setup/runtime.rs @@ -1,22 +1,20 @@ -//! Share a compatible project runtime with first-start dependency installation. +//! Opt in to a compatible Node runtime for first-start dependency installation. use js_semver::{Range, Version}; use vp_js_runtime::NodeProvider; use vp_shared::EnvConfig; -use vt_path::AbsolutePath; use crate::{commands::env::config, error::Error}; -pub(super) async fn resolve(cwd: &AbsolutePath) -> Result { +pub(super) async fn resolve() -> Result, Error> { let env = EnvConfig::get(); - let selector = match env.setup_node_version.as_deref().filter(|value| !value.trim().is_empty()) - { - Some(selector) => selector.trim().to_string(), - None => config::resolve_version(cwd).await?.version, + let Some(selector) = env.setup_node_version.as_deref().map(str::trim).filter(|v| !v.is_empty()) + else { + // Keep the installer on LTS unless a caller explicitly opts in. + // Project discovery and normal Node overrides do not apply here. + return Ok(None); }; - // Session overrides and explicit CI inputs can still contain a selector. - // File-based resolution already returns an exact version, which is reused. - config::resolve_version_alias(&selector, &NodeProvider::new()).await + config::resolve_version_alias(selector, &NodeProvider::new()).await.map(Some) } pub(super) fn supports_cli(version: &str) -> bool { @@ -46,33 +44,35 @@ mod tests { use super::*; - async fn with_project(selector: &str, project_version: &str) -> String { + async fn with_selector(selector: Option<&str>) -> Option { let root = tempfile::tempdir().unwrap(); - let root = vt_path::AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); - let project = root.join("project"); - let nested = project.join("packages/app"); - tokio::fs::create_dir_all(&nested).await.unwrap(); - tokio::fs::write(project.join(".node-version"), project_version).await.unwrap(); - tokio::fs::write(project.join(".nvmrc"), "24.11.0").await.unwrap(); EnvConfig::with_vars_async( [ - (env_vars::VP_HOME, root.as_path().to_str().unwrap()), + (env_vars::VP_HOME, root.path().to_str()), (env_vars::VP_SETUP_NODE_VERSION, selector), - (env_vars::VP_NODE_VERSION, ""), + (env_vars::VP_NODE_VERSION, Some("20.18.0")), ], - |_| async move { resolve(&nested).await.unwrap() }, + |_| async move { + config::write_session_version("20.18.0").await.unwrap(); + let mut defaults = config::load_config().await.unwrap(); + defaults.default_node_version = Some("20.18.0".into()); + config::save_config(&defaults).await.unwrap(); + resolve().await.unwrap() + }, ) .await } #[tokio::test] - async fn explicit_selector_precedes_project() { - assert_eq!(with_project("v22.23.1", "26.0.0").await, "22.23.1"); + async fn explicit_selector_overrides_normal_node_configuration() { + assert_eq!(with_selector(Some(" v22.23.1 ")).await.as_deref(), Some("22.23.1")); } #[tokio::test] - async fn project_selection_uses_existing_precedence_and_parent_lookup() { - assert_eq!(with_project("", "22.23.1").await, "22.23.1"); + async fn absent_or_empty_selector_keeps_the_lts_default() { + for selector in [None, Some(""), Some(" ")] { + assert_eq!(with_selector(selector).await, None); + } } #[test] @@ -90,7 +90,7 @@ mod tests { async fn returns_the_project_version_even_when_bootstrap_needs_a_fallback() { let root = tempfile::tempdir().unwrap(); let file = root.path().join("selected-node"); - let version = with_project("20.18.0", "26.0.0").await; + let version = with_selector(Some("20.18.0")).await.unwrap(); assert!(!supports_cli(&version)); EnvConfig::with_vars_async([(env_vars::VP_SETUP_NODE_VERSION_FILE, &file)], |_| async { write_resolution(&version).await.unwrap(); diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 68d5e44114..818d76e9fc 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -85,7 +85,7 @@ pub const VP_NODE_SKIP_SIGNATURE_VERIFY: &str = "VP_NODE_SKIP_SIGNATURE_VERIFY"; /// Override Node.js version (takes highest priority in version resolution). pub const VP_NODE_VERSION: &str = "VP_NODE_VERSION"; -/// Preferred Node.js selector for first-start installation only. +/// Opt-in Node.js selector for first-start installation; defaults to LTS when unset. pub const VP_SETUP_NODE_VERSION: &str = "VP_SETUP_NODE_VERSION"; /// File used to return the exact project Node.js version to a CI installer. pub const VP_SETUP_NODE_VERSION_FILE: &str = "VP_SETUP_NODE_VERSION_FILE"; From b6a766b347da034966a99c28c79eb3a642454fc3 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 16 Oct 2026 23:54:50 +0800 Subject: [PATCH 3/5] docs: explain installer Node runtime selection --- docs/guide/ci.md | 16 +++++++++++++--- docs/guide/env.md | 2 ++ docs/guide/global-cli.md | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/guide/ci.md b/docs/guide/ci.md index 1f7729ebb1..05f93d6cdc 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -4,7 +4,7 @@ You can use `voidzero-dev/setup-vp` to use Vite+ in CI environments. ## Overview -[`voidzero-dev/setup-vp`](https://github.com/voidzero-dev/setup-vp) provides integrations for GitHub Actions, GitLab CI/CD, and Azure Pipelines. All three install Vite+ and can install project dependencies. The GitHub Action and Azure Pipelines template can also set up Node.js and cache package manager data automatically, while the GitLab CI/CD template uses the Node.js runtime and cache configuration provided by the job. +[`voidzero-dev/setup-vp`](https://github.com/voidzero-dev/setup-vp) provides integrations for GitHub Actions, GitLab CI/CD, and Azure Pipelines. All three install Vite+, can select the project's Node.js version, and can install project dependencies. The GitHub Action and Azure Pipelines template can also cache package-manager data automatically. GitLab CI/CD uses the job's native cache configuration and requires Node.js to start the setup runtime. ## setup-vp Versioning @@ -12,6 +12,16 @@ Set `` in each example to an exact version from the [`setup-vp Run `vp migrate` to replace exact `voidzero-dev/setup-vp@v1` references in GitHub Actions workflows and composite actions under `.github` with the latest exact release known to your Vite+ version. Existing exact versions and commit SHAs remain unchanged. +## Node.js During Installation + +The Vite+ installer uses the latest Node.js LTS by default. With versions that support [`VP_SETUP_NODE_VERSION`](/guide/global-cli#vp-setup-node-version), `setup-vp` can use the project's version for installation too. This avoids a second Node.js download when that version supports both the global CLI and its pinned `pnpm`. + +`setup-vp` selects the version before it starts the installer. An explicit Node.js version takes precedence over a version-file input. Without either input, it checks the working directory and its parents using the [project declaration order](/guide/env#node-js-selection). The integration passes the selection through `VP_SETUP_NODE_VERSION`; the installer itself does not read project declarations. + +Without a selection, with Node.js management disabled, or when the selected version is incompatible, installation uses LTS. Older installers keep their existing behavior. Automatic project discovery does not create a session override, so later commands retain normal directory-based version selection. + +The integration handles these installer variables automatically. Configure `node-version` or `node-version-file` in GitHub Actions and GitLab CI/CD, or `nodeVersion` or `nodeVersionFile` in Azure Pipelines. See the [`setup-vp` documentation](https://github.com/voidzero-dev/setup-vp#inputs) for the complete input reference. + ## GitHub Actions The GitHub Action sets up Vite+, the required Node.js version, and the package manager. This means you usually do not need separate `setup-node`, package-manager setup, dependency installation, or manual dependency caching steps in your workflow. @@ -53,7 +63,7 @@ test: The GitLab CI/CD integration differs from the GitHub Action in a few ways: -- The template does not install Node.js. Use a Node.js image, as shown above, or otherwise provide Node.js in the job. +- The template requires Node.js to start the setup runtime. Use a Node.js image, as shown above, or otherwise provide Node.js in the job. Use `node-version` or `node-version-file` to select a managed project runtime with `vp env use`. - Configure dependency caching with the job's GitLab [`cache`](https://docs.gitlab.com/ci/yaml/#cache) keyword. - Use a Unix-like runner environment with Bash and either `curl` or `wget`. @@ -90,7 +100,7 @@ steps: Pin `ref` and `setupRef` to the same tag or commit SHA for strict reproducibility. -The Azure Pipelines template supports Microsoft-hosted Linux, macOS, and Windows agents. It uses Azure's native `UseNode@1` and `Cache@2` tasks to set up Node.js and cache package-manager data. +The Azure Pipelines template supports Microsoft-hosted Linux, macOS, and Windows agents. `UseNode@1` provides Node.js to start the setup runtime through `bootstrapNodeVersion`. The `nodeVersion` and `nodeVersionFile` parameters select the managed project runtime with `vp env use`. `Cache@2` caches package-manager data. For advanced configuration and the complete parameter reference, see the [`setup-vp` Azure Pipelines documentation](https://github.com/voidzero-dev/setup-vp#azure-pipelines). diff --git a/docs/guide/env.md b/docs/guide/env.md index 7b84c37511..aad2235566 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -41,6 +41,8 @@ To select the project's Node.js version, Vite+ checks the current directory firs If no directory declares a version, Vite+ uses the global default (`vp env default`) and then the latest LTS. +These rules select Node.js for project commands. The installer uses the latest LTS by default. Only [`VP_SETUP_NODE_VERSION`](/guide/global-cli#vp-setup-node-version) changes its dependency-installation runtime; [`setup-vp`](/guide/ci#node-js-during-installation) supplies this override from the CI inputs or project declarations. + `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. ::: tip Using pnpm with Vite+ runtime management diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index a89a91a0f5..82946e851d 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -71,6 +71,8 @@ irm https://vite.plus/ps1 | iex On Windows, you can instead download and run [`vp-setup.exe`](https://setup.viteplus.dev). +The installer uses the latest Node.js LTS to install its JavaScript dependencies. CI integrations can select another compatible version through [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). + After installation, open a new shell and run: ```bash @@ -105,6 +107,22 @@ These variables control the installer scripts and the standalone Windows install $env:VP_VERSION = "1.2.3"; irm https://vite.plus/ps1 | iex ``` +#### `VP_SETUP_NODE_VERSION` + +- **Purpose**: Select the Node.js version used to install the global CLI's JavaScript dependencies. CI integrations such as [`setup-vp`](/guide/ci#node-js-during-installation) set this variable to reuse the project's runtime. +- **Values**: An exact version, a version range, or an alias such as `22.23.1`, `22`, `^22.18.0`, `lts`, or `latest`. The installer resolves the value once to an exact version. +- **Default**: Unset or empty (use the latest Node.js LTS). Project files, `VP_NODE_VERSION`, session overrides, and the user-level default do not change this default. +- **Compatibility**: The resolved version must meet the Node.js requirements of both the global CLI and its pinned `pnpm`. Otherwise, the installer uses LTS. An invalid or unresolvable value causes an error. +- **Scope**: Initial dependency installation only. This variable does not set a project pin, session override, or global default. `vp upgrade` and legacy installers continue to use LTS. + +#### `VP_SETUP_NODE_VERSION_FILE` + +- **Purpose**: Optional output file for integrations that need the exact version resolved from `VP_SETUP_NODE_VERSION`. +- **Contents**: The resolved version, such as `22.23.1`, written after successful dependency installation. If compatibility requires LTS for installation, the file still contains the requested version's resolution. +- **Default**: Unset (write no file). The installer also writes no file when `VP_SETUP_NODE_VERSION` is unset or empty, or when dependency installation is skipped. + +`setup-vp` manages this file automatically. Use its Node.js inputs to configure CI jobs. + #### `VP_HOME` - **Purpose**: Optional pin for the single-root layout. Set it to an absolute path. Vite+ then puts bin, data, cache, config, and state under that directory. The installed CLI reads the same variable. See [Environment](/guide/env). @@ -227,6 +245,7 @@ These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applie - **Purpose**: Override Node.js version - **Default**: None (auto-detected) +- **Scope**: Runtime commands. For the installer's dependency installation, use [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). - **Example**: ```bash # Run a command with a specific Node.js version From be5ee4a95acd3b476fb22e19404f267a85a365a3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 17 Oct 2026 11:11:16 +0800 Subject: [PATCH 4/5] test: align installer progress with runtime preparation order --- .../cli_snapshots/fixtures/download_progress/install.mjs | 2 +- .../fixtures/download_progress/snapshots.toml | 8 ++++---- .../snapshots/installation_progress_failure.md | 8 ++++---- .../snapshots/installation_progress_interactive.md | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs index 1c26414fd3..76c6de0023 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -101,7 +101,7 @@ try { const [code, signal] = await once(child, 'exit'); assert.equal(signal, null); assert.equal(code, failure ? 1 : 0); - assert.deepEqual(phases, ['prepare', 'download', 'dependencies']); + assert.deepEqual(phases, ['download', 'prepare', 'dependencies']); if (failure) { assert.equal(stdout, ''); const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml index b48c82159c..567c213d54 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml @@ -28,10 +28,10 @@ name = "installation_progress_interactive" vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "install.mjs", "interactive"], interactions = [ - { expect-milestone = "install:prepare:ready" }, - { write-key = "enter" }, { expect-milestone = "install:download:ready" }, { write-key = "enter" }, + { expect-milestone = "install:prepare:ready" }, + { write-key = "enter" }, { expect-milestone = "install:dependencies:ready" }, { write-key = "enter" }, ] }] @@ -41,10 +41,10 @@ name = "installation_progress_failure" vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interactions = [ - { expect-milestone = "install:prepare:ready" }, - { write-key = "enter" }, { expect-milestone = "install:download:ready" }, { write-key = "enter" }, + { expect-milestone = "install:prepare:ready" }, + { write-key = "enter" }, { expect-milestone = "install:dependencies:ready" }, { write-key = "enter" }, ] }] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md index f132c3c9ea..24eb896c32 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md @@ -2,22 +2,22 @@ ## `node install.mjs interactive failure` -**→ expect-milestone:** `install:prepare:ready` +**→ expect-milestone:** `install:download:ready` ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Preparing Node.js and pnpm... +⠿ Downloading pnpm ... B ( B/s) ``` **← write-key:** `enter` -**→ expect-milestone:** `install:download:ready` +**→ expect-milestone:** `install:prepare:ready` ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Downloading pnpm ... B ( B/s) +⠿ Preparing Node.js and pnpm... ``` **← write-key:** `enter` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md index bc03a7898e..ee36674471 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -2,22 +2,22 @@ ## `node install.mjs interactive` -**→ expect-milestone:** `install:prepare:ready` +**→ expect-milestone:** `install:download:ready` ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Preparing Node.js and pnpm... +⠿ Downloading pnpm ... B ( B/s) ``` **← write-key:** `enter` -**→ expect-milestone:** `install:download:ready` +**→ expect-milestone:** `install:prepare:ready` ``` Before installation: preserve this output. info: installing vite-plus@... -⠿ Downloading pnpm ... B ( B/s) +⠿ Preparing Node.js and pnpm... ``` **← write-key:** `enter` From 1a22afb6856f57cbd141e204b5dea72792a46e60 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 18 Oct 2026 15:38:28 +0800 Subject: [PATCH 5/5] fix(setup): retry writing the resolved Node version --- .../fixtures/download_progress/install.mjs | 55 +++++++++++++++++-- .../fixtures/download_progress/snapshots.toml | 6 ++ .../installation_resolution_retry.md | 11 ++++ crates/vp_global_cli/src/self_setup.rs | 14 +++-- 4 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs index 76c6de0023..9f923d5bd6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -2,17 +2,28 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { once } from 'node:events'; -import { copyFileSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, +} from 'node:fs'; import { createServer } from 'node:http'; import { join, resolve } from 'node:path'; import { createArchive } from './archive.mjs'; const interactive = process.argv.includes('interactive'); const failure = process.argv.includes('failure'); +const resolutionRetry = process.argv.includes('resolution-retry'); if (interactive) process.stdin.setRawMode(true); const home = resolve('home'); const user = resolve('user'); const external = resolve('external'); +const resolutionDir = resolve('resolution'); +const resolutionFile = join(resolutionDir, 'node-version'); const nodeBin = join(home, 'js_runtime/node/99.0.0/bin'); for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recursive: true }); // Use the runner's real Node for the mock pnpm program, without a network download. @@ -75,10 +86,9 @@ server.listen(0, '127.0.0.1'); await once(server, 'listening'); const mirror = `http://127.0.0.1:${server.address().port}`; -try { - console.log('Before installation: preserve this output.'); +async function runInstaller() { const child = spawn(join(external, 'vp'), [], { - stdio: ['ignore', 'pipe', 'inherit'], + stdio: ['ignore', 'pipe', resolutionRetry ? 'pipe' : 'inherit'], env: { ...process.env, HOME: user, @@ -92,15 +102,48 @@ try { VP_NODE_MANAGER: 'no', VP_SHELL: 'zsh', VP_SKIP_DEPS_INSTALL: '', + VP_SETUP_NODE_VERSION: resolutionRetry ? '20.18.0' : '', + VP_SETUP_NODE_VERSION_FILE: resolutionRetry ? resolutionFile : '', }, }); let stdout = ''; + let stderr = ''; child.stdout.setEncoding('utf8').on('data', (chunk) => { stdout += chunk; }); + child.stderr?.setEncoding('utf8').on('data', (chunk) => { + stderr += chunk; + }); const [code, signal] = await once(child, 'exit'); assert.equal(signal, null); - assert.equal(code, failure ? 1 : 0); + return { code, stdout, stderr }; +} + +try { + console.log('Before installation: preserve this output.'); + let result = await runInstaller(); + if (resolutionRetry) { + assert.equal(result.code, 1, result.stderr); + assert.equal(result.stdout, ''); + const installed = readdirSync(home) + .map((name) => join(home, name)) + .filter((directory) => existsSync(join(directory, 'node_modules/vite-plus/package.json'))); + assert.equal(installed.length, 1); + const marker = join(installed[0], 'bin/.vp-setup-complete'); + assert.equal(existsSync(marker), false); + assert.equal(existsSync(resolutionFile), false); + assert.deepEqual(phases, ['download', 'prepare', 'dependencies']); + console.log('A failed resolution-file write leaves dependencies installed and setup incomplete.'); + + mkdirSync(resolutionDir); + result = await runInstaller(); + assert.equal(result.code, 0, result.stderr); + assert.equal(readFileSync(resolutionFile, 'utf8'), '20.18.0'); + assert.equal(existsSync(marker), true); + console.log('Retry returns the requested Node version without installing dependencies again.'); + } + const { code, stdout, stderr } = result; + assert.equal(code, failure ? 1 : 0, stderr); assert.deepEqual(phases, ['download', 'prepare', 'dependencies']); if (failure) { assert.equal(stdout, ''); @@ -123,6 +166,6 @@ try { if (interactive) process.stdin.setRawMode(false); server.close(); server.closeAllConnections(); - for (const directory of [home, user, external]) + for (const directory of [home, user, external, resolutionDir]) rmSync(directory, { recursive: true, force: true }); } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml index 567c213d54..2c5c312b2f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml @@ -49,6 +49,12 @@ steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interaction { write-key = "enter" }, ] }] +[[case]] +name = "installation_resolution_retry" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "resolution-retry"], tty = false }] + [[case]] name = "installation_progress_non_tty" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md new file mode 100644 index 0000000000..ab15b8c23e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md @@ -0,0 +1,11 @@ +# installation_resolution_retry + +## `node install.mjs resolution-retry` + +``` +Before installation: preserve this output. +A failed resolution-file write leaves dependencies installed and setup incomplete. +Retry returns the requested Node version without installing dependencies again. +Bootstrap stdout contains only shell assignments. +After installation. +``` diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index 223d1b6f46..c782b35d54 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -238,11 +238,15 @@ async fn run(source: &Path, bundled: bool) -> Result { temporary.persist(binary.as_path()).map_err(|error| error.error)?; } } - if !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file() { + let needs_deps = !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file(); + // A standalone setup retry still needs to return its Node selection when dependencies exist. + // In-place upgrades and bundled packages keep their already prepared runtime. + let node_version = + if !skip_deps && (deploy || needs_deps) { runtime::resolve().await? } else { None }; + if needs_deps { output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; if !skip_deps { - let node_version = runtime::resolve().await?; let preferred = node_version.as_deref().filter(|version| runtime::supports_cli(version)); if let Some(version) = node_version.as_deref().filter(|_| preferred.is_none()) { @@ -251,11 +255,11 @@ async fn run(source: &Path, bundled: bool) -> Result { )); } install::install_production_deps(&version_dir, registry, true, preferred).await?; - if let Some(version) = node_version { - runtime::write_resolution(&version).await?; - } } } + if let Some(version) = node_version { + runtime::write_resolution(&version).await?; + } #[cfg(windows)] if !bundled && !version_dir.join("bin/vp-shim.exe").as_path().is_file() { let sibling = source.with_file_name("vp-shim.exe");