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..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,16 +102,49 @@ 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); - assert.deepEqual(phases, ['prepare', 'download', 'dependencies']); + 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, ''); const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); @@ -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 b48c82159c..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 @@ -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,14 +41,20 @@ 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" }, ] }] +[[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_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` 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/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..c782b35d54 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}; @@ -237,13 +238,28 @@ 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 { - install::install_production_deps(&version_dir, registry, true).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!( + "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?; } } + 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"); 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..320cdae090 --- /dev/null +++ b/crates/vp_global_cli/src/self_setup/runtime.rs @@ -0,0 +1,101 @@ +//! 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 crate::{commands::env::config, error::Error}; + +pub(super) async fn resolve() -> Result, Error> { + let env = EnvConfig::get(); + 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); + }; + config::resolve_version_alias(selector, &NodeProvider::new()).await.map(Some) +} + +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_selector(selector: Option<&str>) -> Option { + let root = tempfile::tempdir().unwrap(); + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, root.path().to_str()), + (env_vars::VP_SETUP_NODE_VERSION, selector), + (env_vars::VP_NODE_VERSION, Some("20.18.0")), + ], + |_| 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_overrides_normal_node_configuration() { + assert_eq!(with_selector(Some(" v22.23.1 ")).await.as_deref(), Some("22.23.1")); + } + + #[tokio::test] + async fn absent_or_empty_selector_keeps_the_lts_default() { + for selector in [None, Some(""), Some(" ")] { + assert_eq!(with_selector(selector).await, None); + } + } + + #[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_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(); + }) + .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..818d76e9fc 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"; +/// 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"; + /// 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"; 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