From 1a661a45b7498c7dc7ef3bbc06a680df0abca8a4 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 15:00:29 +0900 Subject: [PATCH 01/20] fix(runtime): pin default Node.js to 24 and automate updates --- .../__tests__/upgrade-deps-utils.spec.ts | 43 +++++++++++- .../scripts/__tests__/upgrade-deps.spec.ts | 21 ++++++ .github/scripts/prewarm-node.sh | 8 +++ .github/scripts/upgrade-deps-utils.ts | 19 ++++++ .github/scripts/upgrade-deps.ts | 27 ++++++++ .github/workflows/upgrade-deps.yml | 4 ++ .../snapshots/command_env_list_remote.md | 2 +- .../tests/cli_snapshots/redact.rs | 2 +- crates/vp_cli_snapshots/tests/redact_unit.rs | 4 +- .../vp_global_cli/src/commands/env/config.rs | 67 ++++++++++++++----- .../vp_global_cli/src/commands/env/default.rs | 15 ++--- .../vp_global_cli/src/commands/env/which.rs | 4 +- crates/vp_global_cli/src/js_executor.rs | 10 +-- crates/vp_global_cli/src/shim/dispatch.rs | 39 +++++++++++ crates/vp_js_runtime/src/lib.rs | 3 + crates/vp_js_runtime/src/runtime.rs | 49 +++++++------- crates/vp_setup/src/install.rs | 36 +++++----- crates/vp_shared/src/env_vars.rs | 3 +- docs/guide/ci.md | 2 +- docs/guide/env.md | 4 +- docs/guide/global-cli.md | 10 +-- 21 files changed, 280 insertions(+), 92 deletions(-) diff --git a/.github/scripts/__tests__/upgrade-deps-utils.spec.ts b/.github/scripts/__tests__/upgrade-deps-utils.spec.ts index fe946c43a6..a2dad57d39 100644 --- a/.github/scripts/__tests__/upgrade-deps-utils.spec.ts +++ b/.github/scripts/__tests__/upgrade-deps-utils.spec.ts @@ -1,8 +1,8 @@ /// -import { expect, test } from 'vite-plus/test'; +import { expect, onTestFinished, test, vi } from 'vite-plus/test'; -import { findLatestStableVersionForMajor } from '../upgrade-deps-utils.ts'; +import { findLatestStableVersionForMajor, getLatestNodeVersion } from '../upgrade-deps-utils.ts'; test('selects the highest stable version from the supported major', () => { expect( @@ -16,3 +16,42 @@ test('selects the highest stable version from the supported major', () => { test('returns undefined when the supported major has no stable release', () => { expect(findLatestStableVersionForMajor(['4.2.0-beta.1', '5.0.0'], 4)).toBeUndefined(); }); + +test('selects the latest stable Node.js 24 release independently of the LTS tag and index order', async () => { + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + const fetchMock = vi.fn().mockResolvedValue( + Response.json([ + { version: 'v26.13.0', lts: 'Lithium' }, + { version: 'v24.9.0', lts: 'Krypton' }, + { version: 'v24.11.0-rc.1', lts: false }, + { version: 'v24.10.2', lts: 'Krypton' }, + { version: 'v24.10.1', lts: 'Krypton' }, + ]), + ); + vi.stubGlobal('fetch', fetchMock); + await expect(getLatestNodeVersion(24)).resolves.toBe('24.10.2'); + expect(fetchMock).toHaveBeenCalledWith('https://nodejs.org/dist/index.json'); +}); + +test.each([ + { + response: () => new Response('Unavailable', { status: 503, statusText: 'Unavailable' }), + error: 'Failed to fetch Node.js releases: 503 Unavailable', + }, + { + response: () => Response.json({ version: 'v24.10.2' }), + error: 'Invalid Node.js release index: expected an array', + }, + { + response: () => Response.json([{ version: 'v26.13.0' }, { version: 'v24.10.0-rc.1' }]), + error: 'No stable 24.x version found for Node.js', + }, +])('rejects an unusable Node.js release index: $error', async ({ response, error }) => { + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response())); + await expect(getLatestNodeVersion(24)).rejects.toThrow(error); +}); diff --git a/.github/scripts/__tests__/upgrade-deps.spec.ts b/.github/scripts/__tests__/upgrade-deps.spec.ts index 7c08e676ab..14e6fe5d77 100644 --- a/.github/scripts/__tests__/upgrade-deps.spec.ts +++ b/.github/scripts/__tests__/upgrade-deps.spec.ts @@ -37,10 +37,19 @@ test.each([false, true])( 'pnpm-workspace.yaml', 'packages/tools/.upstream-versions.json', 'packages/cli/src/utils/constants.ts', + 'crates/vp_js_runtime/src/lib.rs', ]) { mkdirSync(dirname(join(tempDir, file)), { recursive: true }); copyFileSync(join(root, file), join(tempDir, file)); } + const runtimePath = join(tempDir, 'crates/vp_js_runtime/src/lib.rs'); + writeFileSync( + runtimePath, + readFileSync(runtimePath, 'utf8').replace( + /pub const DEFAULT_NODE_VERSION: &str = "[\d.]+";/, + 'pub const DEFAULT_NODE_VERSION: &str = "24.9.0";', + ), + ); const workspacePath = join(tempDir, 'pnpm-workspace.yaml'); writeFileSync( workspacePath, @@ -58,6 +67,14 @@ test.each([false, true])( readFileSync(join(tempDir, 'packages/tools/.upstream-versions.json'), 'utf8'), )['oxc-node'].hash; vi.stubGlobal('fetch', async (url: string) => { + if (url === 'https://nodejs.org/dist/index.json') { + return Response.json([ + { version: 'v26.13.0', lts: 'Lithium' }, + { version: 'v24.9.0', lts: 'Krypton' }, + { version: 'v24.11.0-rc.1', lts: false }, + { version: 'v24.10.2', lts: 'Krypton' }, + ]); + } if (url.startsWith('https://api.github.com/repos/oxc-project/oxc-node/compare/')) { expect(url).toContain(`/compare/${pinnedOxcNode}...`); return Response.json({ status: failInstall ? 'diverged' : 'ahead' }); @@ -133,7 +150,11 @@ test.each([false, true])( expect(readFileSync(workspacePath, 'utf8')).toContain("\n '@oxlint/plugins': =1.88.0\n"); expect(readFileSync(workspacePath, 'utf8')).toContain('\n oxlint: =1.88.0\n'); expect(readFileSync(workspacePath, 'utf8')).toContain('\n oxlint-tsgolint: =7.0.2004\n'); + expect(readFileSync(runtimePath, 'utf8')).toContain( + 'pub const DEFAULT_NODE_VERSION: &str = "24.10.2";', + ); const versions = JSON.parse(readFileSync(join(metaDir, 'versions.json'), 'utf8')); + expect(versions['Node.js default']).toEqual({ old: '24.9.0', new: '24.10.2' }); expect(versions['lint-staged']).toEqual({ old: '16.2.6', new: '17.5.1' }); expect(versions['@oxlint/plugins'].new).toBe('1.88.0'); expect(versions['@oxlint/plugins'].new).toBe(versions.oxlint.new); diff --git a/.github/scripts/prewarm-node.sh b/.github/scripts/prewarm-node.sh index 659ed2a6c6..808dcb9381 100644 --- a/.github/scripts/prewarm-node.sh +++ b/.github/scripts/prewarm-node.sh @@ -3,6 +3,13 @@ set -euo pipefail vp_bin="$1" +# Read the same pin that the built CLI uses, including after dependency upgrades. +default_node_version=$(sed -nE 's/^pub const DEFAULT_NODE_VERSION: &str = "([0-9]+\.[0-9]+\.[0-9]+)";$/\1/p' crates/vp_js_runtime/src/lib.rs) +if [[ ! "$default_node_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + printf 'Could not read the pinned default Node.js version\n' >&2 + exit 1 +fi + prewarm_node() { local version="$1" local TIMEFORMAT="Node.js ${version}: %3R seconds elapsed" @@ -11,6 +18,7 @@ prewarm_node() { } prewarm_node "$(< .node-version)" +prewarm_node "$default_node_version" prewarm_node lts while IFS= read -r node_version; do prewarm_node "$node_version" diff --git a/.github/scripts/upgrade-deps-utils.ts b/.github/scripts/upgrade-deps-utils.ts index 831734aa28..78521a9c2e 100644 --- a/.github/scripts/upgrade-deps-utils.ts +++ b/.github/scripts/upgrade-deps-utils.ts @@ -85,3 +85,22 @@ export function findLatestStableVersionForMajor( } return latest?.version; } + +export async function getLatestNodeVersion(major: number): Promise { + const response = await fetch('https://nodejs.org/dist/index.json'); + if (!response.ok) { + throw new Error(`Failed to fetch Node.js releases: ${response.status} ${response.statusText}`); + } + const releases: unknown = await response.json(); + if (!Array.isArray(releases)) { + throw new Error('Invalid Node.js release index: expected an array'); + } + const versions = releases.flatMap((release: { version?: unknown } | null) => + typeof release?.version === 'string' ? [release.version.replace(/^v/, '')] : [], + ); + const version = findLatestStableVersionForMajor(versions, major); + if (!version) { + throw new Error(`No stable ${major}.x version found for Node.js`); + } + return version; +} diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index d55c5b4394..645d8de970 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { findLatestStableVersionForMajor, getBundledDependencyRanges, + getLatestNodeVersion, } from './upgrade-deps-utils.ts'; const ROOT = process.cwd(); @@ -79,6 +80,8 @@ const STABLE_SEMVER_TAG_RE = /^v?\d+\.\d+\.\d+$/; // Vitest major upgrades can change the bundled API, export shims, and CLI // behavior. Advance this only after Vite+ has adapted to the new major. const SUPPORTED_VITEST_MAJOR = 5; +// The default runtime must not follow a new LTS major without a compatibility review. +const SUPPORTED_NODE_MAJOR = 24; const isFullSha = (s: string): boolean => /^[0-9a-f]{40}$/.test(s); @@ -420,6 +423,26 @@ async function updateVitestVersionConstant(vitestVersion: string): Promise console.log('Updated packages/cli/src/utils/constants.ts'); } +// ============ Update the installer and fallback Node.js version ============ +function updateDefaultNodeVersion(nodeVersion: string): void { + const filePath = path.join(ROOT, 'crates/vp_js_runtime/src/lib.rs'); + const content = fs.readFileSync(filePath, 'utf8'); + const pattern = /pub const DEFAULT_NODE_VERSION: &str = "(\d+\.\d+\.\d+)";/; + let oldVersion: string | undefined; + const updated = content.replace(pattern, (_match: string, captured: string) => { + oldVersion = captured; + return `pub const DEFAULT_NODE_VERSION: &str = "${nodeVersion}";`; + }); + if (oldVersion === undefined) { + throw new Error( + `Failed to match DEFAULT_NODE_VERSION in ${filePath} — the pattern ${pattern} is stale, ` + + `please update it in .github/scripts/upgrade-deps.ts`, + ); + } + fs.writeFileSync(filePath, updated); + recordChange('Node.js default', oldVersion, nodeVersion); +} + // ============ Write metadata files for PR description ============ const formatVersion = (v: Change): string => { if (v.tag) { @@ -527,6 +550,7 @@ async function upgradeDependencies(): Promise { console.log('Fetching latest versions…'); const [ + nodeVersion, vitestVersion, tsdownVersion, lightningcssVersion, @@ -540,6 +564,7 @@ async function upgradeDependencies(): Promise { oxcProjectTypesVersion, oxcParserVersion, ] = await Promise.all([ + getLatestNodeVersion(SUPPORTED_NODE_MAJOR), getLatestNpmVersionForMajor('vitest', SUPPORTED_VITEST_MAJOR), getLatestNpmVersion('tsdown'), // Mirror exactly what the bundled @tsdown/css depends on. @@ -555,6 +580,7 @@ async function upgradeDependencies(): Promise { getLatestNpmVersion('oxc-parser'), ]); + console.log(`Node.js default: ${nodeVersion}`); console.log(`vitest: ${vitestVersion}`); console.log(`tsdown: ${tsdownVersion}`); console.log(`lightningcss (from @tsdown/css): ${lightningcssVersion}`); @@ -585,6 +611,7 @@ async function upgradeDependencies(): Promise { oxcParser: oxcParserVersion, }); await updateVitestVersionConstant(vitestVersion); + updateDefaultNodeVersion(nodeVersion); // Keep the selected versions available if installation or upstream sync fails. writeMetaFiles(); diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index c3496c07dc..2191ef4536 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -107,6 +107,10 @@ jobs: ### Background - Upgrade script: `./.github/scripts/upgrade-deps.ts` + - The installer and fallback Node.js runtime are pinned to the latest + stable `24.x` release in `crates/vp_js_runtime/src/lib.rs`. + Keep the exact version selected by the upgrade script. Do not replace + it with a moving LTS alias or advance the major version. - Vitest is capped at the latest stable `5.x` release. Keep the exact version selected by the upgrade script when merging upstream catalogs. Official Vitest packages must use that version. Do not bypass the cap. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index 772f8c81c5..33c10b14c0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -7,7 +7,7 @@ An unconfigured Node.js default explains the effective fallback without claiming ``` VITE+ - The Unified Toolchain for the Web -No default Node.js version configured. Using latest LTS (). +No default Node.js version configured. Using Vite+'s pinned version (). Run 'vp env default ' to set a default. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 453fec079d..f8cb4a5a14 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -232,7 +232,7 @@ static MANAGED_TEST_VERSION_RE: LazyLock = LazyLock::new(|| { // context so fixture-pinned versions elsewhere stay assertable. static WHICH_NODE_VERSION_RE: LazyLock = LazyLock::new(|| { regex::Regex::new( - r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |No default Node\.js version configured\. Using latest LTS \(|Currently resolves to:\s+|^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, + r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |No default Node\.js version configured\. Using Vite\+'s pinned version \(|Currently resolves to:\s+|^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, ) .unwrap() }); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index 22b51489d8..a854b26e2c 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -436,7 +436,7 @@ fn masks_managed_node_versions_in_environment_output() { "Node: 24.18.1\n", "\"nodeVersion\": \"24.18.1\"\n", "✓ Default Node.js version set to lts (currently 24.18.1)\n", - "No default Node.js version configured. Using latest LTS (24.18.1).\n", + "No default Node.js version configured. Using Vite+'s pinned version (24.18.1).\n", " Currently resolves to: 24.18.1\n", "just-a-normal-package@0.0.0 24.18.1 just-a-normal-package\n", "fixture pin: 22.18.0\n", @@ -448,7 +448,7 @@ fn masks_managed_node_versions_in_environment_output() { "Node: \n", "\"nodeVersion\": \"\"\n", "✓ Default Node.js version set to lts (currently )\n", - "No default Node.js version configured. Using latest LTS ().\n", + "No default Node.js version configured. Using Vite+'s pinned version ().\n", " Currently resolves to: \n", "just-a-normal-package@0.0.0 just-a-normal-package\n", "fixture pin: 22.18.0\n", diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 136b4cbaa4..f34fb52154 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -9,8 +9,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Deserializer, Serialize, de}; use vp_js_runtime::{ - NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, - read_package_json, resolve_node_version, + DEFAULT_NODE_VERSION, NodeProvider, VersionSource, is_valid_version, normalize_version, + read_nvmrc_file, read_package_json, resolve_node_version, }; use vp_pm_cli::PackageManagerType; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -344,7 +344,7 @@ pub async fn delete_session_package_manager(kind: PackageManagerType) -> Result< /// 4. `package.json#engines.node` in current or parent directories /// 5. `.nvmrc` file in current or parent directories /// 6. User default from config.json -/// 7. Latest LTS version +/// 7. Node.js release pinned by Vite+ pub async fn resolve_version(cwd: &AbsolutePath) -> Result { // Session override via environment variable (set by `vp env use`) if let Some(env_version) = vp_shared::EnvConfig::get().node_version.as_deref() { @@ -524,14 +524,13 @@ pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result Result { } } None => { - let provider = vp_js_runtime::NodeProvider::new(); - match provider.resolve_latest_version().await { - Ok(version) => { - vp_shared::output::print_stdout_line(format_args!( - "No default Node.js version configured. Using latest LTS ({version})." - )); - } - Err(_) => vp_shared::output::print_stdout_line(format_args!( - "No default Node.js version configured." - )), - } + vp_shared::output::print_stdout_line(format_args!( + "No default Node.js version configured. Using Vite+'s pinned version ({}).", + vp_js_runtime::DEFAULT_NODE_VERSION + )); vp_shared::output::print_stdout_line(format_args!( " Run 'vp env default ' to set a default." )); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 95e6dc1d22..a287197d89 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -266,11 +266,11 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result) -> String { match source { s if s == VERSION_ENV_VAR => format!("{s} (session)"), - "lts" => "lts (fallback)".to_string(), + "builtin" => "Vite+ default (fallback)".to_string(), _ => match source_path { Some(path) => path.as_path().display().to_string(), None => source.to_string(), diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 1f3a6e90e9..a408bc45a0 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -181,7 +181,7 @@ impl JsExecutor { /// 3. Project sources (.node-version, devEngines.runtime, engines.node, .nvmrc) — /// delegates to `download_runtime_for_project()` for cache-aware resolution /// 4. User default from config.json - /// 5. Latest LTS + /// 5. Node.js release pinned by Vite+ pub async fn ensure_project_runtime( &mut self, project_path: &AbsolutePath, @@ -212,8 +212,8 @@ impl JsExecutor { // 3. Check if project has any *valid* version source. // resolve_node_version returns Some for any non-empty value, // even invalid ones. We must validate before routing to - // download_runtime_for_project, which falls to LTS on all-invalid - // and would skip the user's configured default. + // download_runtime_for_project, which uses the built-in default when + // all sources are invalid and would skip the user's configured default. let has_valid_project_source = has_valid_version_source(project_path).await?; let runtime = if has_valid_project_source { @@ -222,7 +222,7 @@ impl JsExecutor { // and intra-project fallback chain download_runtime_for_project(project_path).await? } else { - // No valid project source, fall back to user default from config, then LTS + // No valid project source: use the configured default, then the built-in default. let resolution = config::resolve_version(project_path).await?; download_runtime(JsRuntimeType::Node, &resolution.version).await? }; @@ -492,7 +492,7 @@ fn local_vite_plus_is_older(local: &str, global: &str) -> bool { /// later call `normalize_version` on the same values. /// /// Returns `false` when all sources are missing or invalid, so the caller -/// can fall through to the user's configured default instead of LTS. +/// can fall through to the user's configured default instead of the built-in default. async fn has_valid_version_source(project_path: &AbsolutePath) -> Result { Ok(config::resolve_project_version_source(project_path, false).await?.is_some()) } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 97555c7642..841c66409c 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1202,6 +1202,14 @@ async fn cached_project_source_still_current( cwd: &AbsolutePathBuf, entry: &ResolveCacheEntry, ) -> Result { + // A CLI upgrade can replace the implicit LTS fallback or update its exact pin. + // Explicit LTS selections use the project or configured-default source instead. + if entry.source == "lts" + || (entry.source == "builtin" && entry.version != vp_js_runtime::DEFAULT_NODE_VERSION) + { + return Ok(false); + } + let Some(current) = config::resolve_project_version_source(cwd, false).await.map_err(|e| format!("{e}"))? else { @@ -1442,6 +1450,37 @@ mod tests { .await; } + #[tokio::test] + async fn test_resolve_with_cache_refreshes_fallback_after_cli_upgrade() { + for (source, version) in [("lts", "26.13.0"), ("builtin", "24.11.0")] { + let temp = TempDir::new().unwrap(); + let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); + std::fs::create_dir(&cwd).unwrap(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_path())], + |_| async { + let mut entry = cache_entry(source, None); + entry.version = version.to_string(); + let mut cache = ResolveCache::default(); + cache.insert(&cwd, entry); + cache.save(&cache::get_cache_path().unwrap()); + + let resolved = resolve_with_cache(&cwd).await.unwrap(); + + assert_eq!(resolved.version, vp_js_runtime::DEFAULT_NODE_VERSION); + assert_eq!(resolved.source, "builtin"); + let saved = ResolveCache::load(&cache::get_cache_path().unwrap()); + assert_eq!( + saved.get(&cwd).unwrap().version, + vp_js_runtime::DEFAULT_NODE_VERSION + ); + }, + ) + .await; + } + } + #[tokio::test] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { let temp = TempDir::new().unwrap(); diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index f7f75d092d..72d5ddb8c1 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -53,6 +53,9 @@ mod provider; mod providers; mod runtime; +/// Default Node.js release, kept within Node 24 by `.github/scripts/upgrade-deps.ts`. +pub const DEFAULT_NODE_VERSION: &str = "24.22.2"; + pub use dev_engines::{ nvmrc_version_spans, parse_node_version_content, read_node_version_file, read_nvmrc_file, }; diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index e9618cad49..969ffe4841 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -436,8 +436,7 @@ pub async fn resolve_node_version( /// 3. `engines.node` in package.json /// 4. `.nvmrc` file /// -/// If no version source is found, uses the latest installed version from cache, -/// or falls back to the latest LTS version from the network. +/// If no version source is found, uses the Node.js release pinned by Vite+. /// /// When the resolved version from the highest priority source does NOT satisfy /// constraints from lower priority sources, a warning is emitted. @@ -525,9 +524,8 @@ async fn resolve_version_for_project( cache_dir: &AbsolutePath, ) -> Result { if version_req.is_empty() { - // No source specified - fetch latest LTS from network - tracing::debug!("No version source specified, fetching latest LTS from network"); - return provider.resolve_latest_version().await; + // An upstream LTS promotion must not change an implicit runtime selection. + return Ok(crate::DEFAULT_NODE_VERSION.into()); } // Handle LTS aliases (lts/*, lts/iron, lts/-1) @@ -720,7 +718,12 @@ mod tests { then.status(200).body(NODE_INDEX); }) .await; - let entries: Vec = serde_json::from_str(NODE_INDEX).unwrap(); + let mut entries: Vec = serde_json::from_str(NODE_INDEX).unwrap(); + // The pinned fallback does not need to appear in the remote version index. + entries.push(NodeVersionEntry { + version: format!("v{}", crate::DEFAULT_NODE_VERSION).into(), + lts: crate::LtsInfo::NotLts, + }); let mut archives = Vec::new(); for entry in &entries { let version = entry.version.trim_start_matches('v'); @@ -822,16 +825,16 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_no_dev_engines() { - with_mock_mirror(&["24.11.0"], |temp_path| async move { + with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { // Create package.json without devEngines (minified, will use default 2-space indent) let package_json = r#"{"name": "test-project"}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // Should download the latest LTS + // Should download the release pinned by Vite+ assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), "24.11.0"); + assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); // Should NOT write .node-version assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); @@ -846,7 +849,7 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_does_not_write_back_when_no_version() { - with_mock_mirror(&["24.11.0"], |temp_path| async move { + with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { // Create package.json with runtime but no version let package_json = r#"{ "name": "test-project", @@ -860,7 +863,7 @@ mod tests { tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.version(), "24.11.0"); + assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); // .node-version should NOT be written (auto-write was removed) assert!( @@ -927,13 +930,13 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_no_package_json() { - with_mock_mirror(&["24.11.0"], |temp_path| async move { + with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { // No package.json file let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // Should download the latest LTS + // Should download the release pinned by Vite+ assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), "24.11.0"); + assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); // Should NOT write .node-version assert!( @@ -1332,7 +1335,7 @@ mod tests { #[tokio::test] async fn test_invalid_node_version_file_is_ignored() { - with_mock_mirror(&["24.11.0"], |temp_path| async move { + with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { // Create .node-version with invalid version tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); @@ -1340,40 +1343,40 @@ mod tests { let package_json = r#"{"name": "test-project"}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should fall through to fetch latest LTS since .node-version is invalid + // Should use the pinned default since .node-version is invalid let runtime = download_runtime_for_project(&temp_path).await.unwrap(); assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), "24.11.0"); + assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); }) .await; } #[tokio::test] async fn test_invalid_engines_node_is_ignored() { - with_mock_mirror(&["24.11.0"], |temp_path| async move { + with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { // Create package.json with invalid engines.node let package_json = r#"{"engines":{"node":"invalid"}}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should fall through to fetch latest LTS since engines.node is invalid + // Should use the pinned default since engines.node is invalid let runtime = download_runtime_for_project(&temp_path).await.unwrap(); assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), "24.11.0"); + assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); }) .await; } #[tokio::test] async fn test_invalid_dev_engines_runtime_is_ignored() { - with_mock_mirror(&["24.11.0"], |temp_path| async move { + with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { // Create package.json with invalid devEngines.runtime version let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"invalid"}}}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should fall through to fetch latest LTS since devEngines.runtime is invalid + // Should use the pinned default since devEngines.runtime is invalid let runtime = download_runtime_for_project(&temp_path).await.unwrap(); assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), "24.11.0"); + assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); }) .await; } diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 98aa226ce3..81718f6eeb 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -13,7 +13,7 @@ use std::{ use flate2::read::GzDecoder; use tar::Archive; -use vp_js_runtime::{JsRuntimeType, NodeProvider, download_runtime}; +use vp_js_runtime::{DEFAULT_NODE_VERSION, JsRuntimeType, download_runtime}; use vp_pm_cli::{PackageManagerType, download_package_manager}; use vp_shared::EnvConfig; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -302,21 +302,11 @@ async fn prepare_install_runtime( .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_version = preferred_node_version + .filter(|version| supports_pnpm_node(&package, version)) + .unwrap_or(DEFAULT_NODE_VERSION); let node_runtime = - download_runtime(JsRuntimeType::Node, &node_version).await.map_err(|error| { + 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)) @@ -680,15 +670,15 @@ 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}; + async fn bootstrap_reuses_compatible_node_and_otherwise_uses_pinned_default() { + use vp_js_runtime::{JsRuntimeProvider, NodeProvider, 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":"v26.13.0", "lts":"Newer LTS"}, {"version":"v22.23.1", "lts":"Jod"} ])); }); @@ -711,7 +701,7 @@ mod tests { } 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"] { + for version in ["22.23.1", DEFAULT_NODE_VERSION] { let binary = env .dirs .data @@ -727,10 +717,14 @@ mod tests { assert_eq!(entry, pnpm.join("bin/pnpm.cjs")); index.assert_calls(0); + let (runtime, _) = prepare_install_runtime(None).await.unwrap(); + assert_eq!(runtime.version(), DEFAULT_NODE_VERSION); + 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); + assert_eq!(runtime.version(), DEFAULT_NODE_VERSION); + index.assert_calls(0); }, ) .await; diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 43cc73c9e2..03c9f6bc43 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -85,7 +85,8 @@ 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. +/// Opt-in Node.js selector for first-start installation. +/// Defaults to the pinned Node.js release 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"; diff --git a/docs/guide/ci.md b/docs/guide/ci.md index 05f93d6cdc..dcb1047890 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -14,7 +14,7 @@ Run `vp migrate` to replace exact `voidzero-dev/setup-vp@v1` references in GitHu ## 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`. +The Vite+ installer uses a pinned Node.js 24 release by default. The dependency-upgrade workflow updates this pin within Node.js 24. 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. diff --git a/docs/guide/env.md b/docs/guide/env.md index f7ef2eb8e6..a9f54b3221 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -39,9 +39,9 @@ To select the project's Node.js version, Vite+ checks the current directory firs 3. `engines.node` in `package.json` 4. `.nvmrc` file -If no directory declares a version, Vite+ uses the global default (`vp env default`) and then the latest LTS. +If no directory declares a version, Vite+ uses the global default (`vp env default`) and then its pinned Node.js 24 release. Dependency upgrades keep this pin current within Node.js 24; a new LTS major does not change the fallback. -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. +These rules select Node.js for project commands. The installer uses the same pinned Node.js 24 release 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. diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 820fc36c14..9f4a150cb4 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -71,7 +71,7 @@ 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). +The installer uses a pinned Node.js 24 release to install its JavaScript dependencies. The dependency-upgrade workflow updates this pin within Node.js 24. CI integrations can select another compatible version through [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). After installation, open a new shell and run: @@ -111,14 +111,14 @@ These variables control the installer scripts and the standalone Windows install - **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. +- **Default**: Unset or empty (use the pinned Node.js 24 release). 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 the pinned Node.js 24 release. 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` also uses the pinned Node.js 24 release. Legacy installers use the runtime policy of the selected older CLI version. #### `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. +- **Contents**: The resolved version, such as `22.23.1`, written after successful dependency installation. If compatibility requires the pinned Node.js 24 release 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. From 8f52710901c48b525d761db9380c82f5fd6ec991 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 15:05:09 +0900 Subject: [PATCH 02/20] docs(runtime): clarify pinned installer default --- crates/vp_global_cli/src/self_setup/runtime.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vp_global_cli/src/self_setup/runtime.rs b/crates/vp_global_cli/src/self_setup/runtime.rs index 320cdae090..ce2583ab3e 100644 --- a/crates/vp_global_cli/src/self_setup/runtime.rs +++ b/crates/vp_global_cli/src/self_setup/runtime.rs @@ -10,7 +10,7 @@ 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. + // Keep the installer's pinned default unless a caller explicitly opts in. // Project discovery and normal Node overrides do not apply here. return Ok(None); }; @@ -69,7 +69,7 @@ mod tests { } #[tokio::test] - async fn absent_or_empty_selector_keeps_the_lts_default() { + async fn absent_or_empty_selector_keeps_the_pinned_default() { for selector in [None, Some(""), Some(" ")] { assert_eq!(with_selector(selector).await, None); } From dbe275409ff7f8a3ee79fac2072936852807e84e Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 15:14:38 +0900 Subject: [PATCH 03/20] fix(runtime): derive the shared Node pin from .node-version --- .../action.yml | 1 + .github/renovate.json | 5 ++++ .../scripts/__tests__/upgrade-deps.spec.ts | 17 +++---------- .github/scripts/prewarm-node.sh | 9 +------ .github/scripts/upgrade-deps.ts | 25 ++++++------------- .github/workflows/build-docs-fork-preview.yml | 2 +- .github/workflows/ci.yml | 15 +++++++++-- .../workflows/deploy-docs-fork-preview.yml | 2 +- .github/workflows/e2e-test.yml | 1 + .github/workflows/release.yml | 2 +- .github/workflows/test-standalone-install.yml | 3 ++- .github/workflows/test-vp-create.yml | 4 ++- .github/workflows/upgrade-deps.yml | 10 ++++++-- .node-version | 2 +- CONTRIBUTING.md | 3 +++ crates/vp_js_runtime/src/lib.rs | 5 ++-- docs/guide/ci.md | 2 +- docs/guide/global-cli.md | 2 +- flake.nix | 3 ++- 19 files changed, 60 insertions(+), 53 deletions(-) diff --git a/.github/actions/compute-native-cache-input-hash/action.yml b/.github/actions/compute-native-cache-input-hash/action.yml index 445bb83df5..56af38facf 100644 --- a/.github/actions/compute-native-cache-input-hash/action.yml +++ b/.github/actions/compute-native-cache-input-hash/action.yml @@ -33,6 +33,7 @@ runs: 'packages/tools/src/patch-oxc-node.ts', 'packages/tools/src/cargo-toml.ts', 'rust-toolchain.toml', + '.node-version', 'Cargo.lock', 'Cargo.toml', '.cargo/config.toml', diff --git a/.github/renovate.json b/.github/renovate.json index 0ded9486f7..1c1b738f1e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -53,6 +53,11 @@ } ], "packageRules": [ + { + "description": "Manage the shared Node.js pin through .github/workflows/upgrade-deps.yml.", + "matchFileNames": [".node-version"], + "enabled": false + }, { "description": "Require review for Windows SDK and cargo-xwin updates.", "matchManagers": ["custom.regex"], diff --git a/.github/scripts/__tests__/upgrade-deps.spec.ts b/.github/scripts/__tests__/upgrade-deps.spec.ts index 14e6fe5d77..75b7567350 100644 --- a/.github/scripts/__tests__/upgrade-deps.spec.ts +++ b/.github/scripts/__tests__/upgrade-deps.spec.ts @@ -37,19 +37,12 @@ test.each([false, true])( 'pnpm-workspace.yaml', 'packages/tools/.upstream-versions.json', 'packages/cli/src/utils/constants.ts', - 'crates/vp_js_runtime/src/lib.rs', ]) { mkdirSync(dirname(join(tempDir, file)), { recursive: true }); copyFileSync(join(root, file), join(tempDir, file)); } - const runtimePath = join(tempDir, 'crates/vp_js_runtime/src/lib.rs'); - writeFileSync( - runtimePath, - readFileSync(runtimePath, 'utf8').replace( - /pub const DEFAULT_NODE_VERSION: &str = "[\d.]+";/, - 'pub const DEFAULT_NODE_VERSION: &str = "24.9.0";', - ), - ); + const nodeVersionPath = join(tempDir, '.node-version'); + writeFileSync(nodeVersionPath, '24.9.0\n'); const workspacePath = join(tempDir, 'pnpm-workspace.yaml'); writeFileSync( workspacePath, @@ -150,11 +143,9 @@ test.each([false, true])( expect(readFileSync(workspacePath, 'utf8')).toContain("\n '@oxlint/plugins': =1.88.0\n"); expect(readFileSync(workspacePath, 'utf8')).toContain('\n oxlint: =1.88.0\n'); expect(readFileSync(workspacePath, 'utf8')).toContain('\n oxlint-tsgolint: =7.0.2004\n'); - expect(readFileSync(runtimePath, 'utf8')).toContain( - 'pub const DEFAULT_NODE_VERSION: &str = "24.10.2";', - ); + expect(readFileSync(nodeVersionPath, 'utf8')).toBe('24.10.2\n'); const versions = JSON.parse(readFileSync(join(metaDir, 'versions.json'), 'utf8')); - expect(versions['Node.js default']).toEqual({ old: '24.9.0', new: '24.10.2' }); + expect(versions['Node.js']).toEqual({ old: '24.9.0', new: '24.10.2' }); expect(versions['lint-staged']).toEqual({ old: '16.2.6', new: '17.5.1' }); expect(versions['@oxlint/plugins'].new).toBe('1.88.0'); expect(versions['@oxlint/plugins'].new).toBe(versions.oxlint.new); diff --git a/.github/scripts/prewarm-node.sh b/.github/scripts/prewarm-node.sh index 808dcb9381..fe484ce0e2 100644 --- a/.github/scripts/prewarm-node.sh +++ b/.github/scripts/prewarm-node.sh @@ -3,13 +3,6 @@ set -euo pipefail vp_bin="$1" -# Read the same pin that the built CLI uses, including after dependency upgrades. -default_node_version=$(sed -nE 's/^pub const DEFAULT_NODE_VERSION: &str = "([0-9]+\.[0-9]+\.[0-9]+)";$/\1/p' crates/vp_js_runtime/src/lib.rs) -if [[ ! "$default_node_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - printf 'Could not read the pinned default Node.js version\n' >&2 - exit 1 -fi - prewarm_node() { local version="$1" local TIMEFORMAT="Node.js ${version}: %3R seconds elapsed" @@ -17,8 +10,8 @@ prewarm_node() { time VP_NODE_VERSION="$version" "$vp_bin" node --version || return "$?" } +# Development, CI, and the built-in runtime all use this pin. prewarm_node "$(< .node-version)" -prewarm_node "$default_node_version" prewarm_node lts while IFS= read -r node_version; do prewarm_node "$node_version" diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 645d8de970..205ed54e30 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -423,24 +423,15 @@ async function updateVitestVersionConstant(vitestVersion: string): Promise console.log('Updated packages/cli/src/utils/constants.ts'); } -// ============ Update the installer and fallback Node.js version ============ +// ============ Update the development, CI, and default Node.js version ============ function updateDefaultNodeVersion(nodeVersion: string): void { - const filePath = path.join(ROOT, 'crates/vp_js_runtime/src/lib.rs'); - const content = fs.readFileSync(filePath, 'utf8'); - const pattern = /pub const DEFAULT_NODE_VERSION: &str = "(\d+\.\d+\.\d+)";/; - let oldVersion: string | undefined; - const updated = content.replace(pattern, (_match: string, captured: string) => { - oldVersion = captured; - return `pub const DEFAULT_NODE_VERSION: &str = "${nodeVersion}";`; - }); - if (oldVersion === undefined) { - throw new Error( - `Failed to match DEFAULT_NODE_VERSION in ${filePath} — the pattern ${pattern} is stale, ` + - `please update it in .github/scripts/upgrade-deps.ts`, - ); + const filePath = path.join(ROOT, '.node-version'); + const oldVersion = fs.readFileSync(filePath, 'utf8').trim(); + if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(oldVersion)) { + throw new Error(`Expected an exact stable Node.js version in ${filePath}`); } - fs.writeFileSync(filePath, updated); - recordChange('Node.js default', oldVersion, nodeVersion); + fs.writeFileSync(filePath, `${nodeVersion}\n`); + recordChange('Node.js', oldVersion, nodeVersion); } // ============ Write metadata files for PR description ============ @@ -580,7 +571,7 @@ async function upgradeDependencies(): Promise { getLatestNpmVersion('oxc-parser'), ]); - console.log(`Node.js default: ${nodeVersion}`); + console.log(`Node.js: ${nodeVersion}`); console.log(`vitest: ${vitestVersion}`); console.log(`tsdown: ${tsdownVersion}`); console.log(`lightningcss (from @tsdown/css): ${lightningcssVersion}`); diff --git a/.github/workflows/build-docs-fork-preview.yml b/.github/workflows/build-docs-fork-preview.yml index 8d648e544e..e982c499e6 100644 --- a/.github/workflows/build-docs-fork-preview.yml +++ b/.github/workflows/build-docs-fork-preview.yml @@ -58,7 +58,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '24' + node-version-file: .node-version package-manager-cache: false - run: node --test .github/scripts/__tests__/docs-fork-preview.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d12939fc9..72d29ce5dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,18 @@ jobs: # as .md files (fixtures/**/snapshots/*.md), which the code filter's # markdown exclusion would otherwise classify as docs-only. code-changed: ${{ steps.filter.outputs.code == 'true' || steps.filter.outputs.snapshots == 'true' }} + node-version: ${{ steps.node-version.outputs.version }} steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Read Node.js version + id: node-version + run: | + node_version=$(< .node-version) + if [[ ! "$node_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo '::error::.node-version must contain an exact Node.js version' + exit 1 + fi + echo "version=$node_version" >> "$GITHUB_OUTPUT" - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: @@ -282,7 +292,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 container: - image: node:26.12.1-alpine + image: node:${{ needs.detect-changes.outputs.node-version }}-alpine env: # Mirrors of official Node.js releases do not provide musl archives. VP_NODE_DIST_MIRROR: https://unofficial-builds.nodejs.org/download/release @@ -1381,13 +1391,14 @@ jobs: # Mirrors of official Node.js releases do not provide musl archives. VP_NODE_DIST_MIRROR: https://unofficial-builds.nodejs.org/download/release run: | + node_version=$(< .node-version) docker run --rm \ -e CI=true \ -e GITHUB_ACTIONS=true \ -e VP_NODE_DIST_MIRROR \ -v "${{ github.workspace }}:/workspace" \ -w /workspace \ - node:22.23.4-alpine sh -ec " + "node:${node_version}-alpine" sh -ec " apk add --no-cache bash curl ca-certificates git zip unzip # Install pnpm and re-resolve optional dependencies for musl. diff --git a/.github/workflows/deploy-docs-fork-preview.yml b/.github/workflows/deploy-docs-fork-preview.yml index 7bb35031a6..4b4e2d9a5f 100644 --- a/.github/workflows/deploy-docs-fork-preview.yml +++ b/.github/workflows/deploy-docs-fork-preview.yml @@ -103,7 +103,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '24' + node-version-file: .node-version package-manager-cache: false # Install only the deployment tool, outside both the checkout and the diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 31ae03983e..0f27fcff8e 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -38,6 +38,7 @@ jobs: with: filters: | related-files: + - '.node-version' - 'packages/**/build.ts' - .github/workflows/e2e-test.yml - .github/workflows/reusable-ecosystem-ci.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0014131339..6fa479ced1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '24.22.1' + node-version-file: .node-version package-manager-cache: false registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 45b5e32bda..a38c4e7ff2 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -6,6 +6,7 @@ on: workflow_dispatch: pull_request: paths: + - '.node-version' - 'packages/cli/install.sh' - 'packages/cli/install.ps1' - 'packages/cli/tests/fixtures/provenance-registry.mjs' @@ -1528,7 +1529,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version-file: .node-version - name: Pin VP_HOME to USERPROFILE # Namespace's Windows runners run jobs under a service account whose real diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index ff8a9b55d7..31ce989db7 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -8,6 +8,7 @@ on: branches: - main paths: + - '.node-version' - 'packages/cli/src/create/**' - 'packages/cli/templates/**' - 'packages/cli/src/migration/**' @@ -41,6 +42,7 @@ jobs: with: filters: | related-files: + - '.node-version' - 'packages/cli/src/create/**' - 'packages/cli/templates/**' - 'packages/cli/src/migration/**' @@ -194,7 +196,7 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 24 + node-version-file: .node-version - name: Download vite-plus packages uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 2191ef4536..41a1b70146 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -46,6 +46,12 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node .github/scripts/upgrade-deps.ts + - name: Use the updated Node.js pin + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .node-version + package-manager-cache: false + - name: Build uses: ./.github/actions/build-upstream id: build-upstream @@ -107,8 +113,8 @@ jobs: ### Background - Upgrade script: `./.github/scripts/upgrade-deps.ts` - - The installer and fallback Node.js runtime are pinned to the latest - stable `24.x` release in `crates/vp_js_runtime/src/lib.rs`. + - Development, CI, the installer, and fallback Node.js runtime use the + latest stable `24.x` release pinned in `.node-version`. Keep the exact version selected by the upgrade script. Do not replace it with a moving LTS alias or advance the major version. - Vitest is capped at the latest stable `5.x` release. Keep the exact diff --git a/.node-version b/.node-version index b8b23ca93c..b74495a07d 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -22.23.4 +24.22.2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a8202a5c5..d76d132e93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,9 @@ You'll need the following tools installed on your system: - Rust and Cargo - cargo-binstall +The root `.node-version` also supplies the default Node.js version embedded in the CLI. +The dependency-upgrade workflow keeps this shared development, CI, and installer pin on Node.js 24. + If you haven't installed Node.js and pnpm, we recommend using Vite+ to manage them. See [environment management](https://viteplus.dev/guide/env) for details. ### macOS / Linux diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index 72d5ddb8c1..82cb44dd22 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -53,8 +53,9 @@ mod provider; mod providers; mod runtime; -/// Default Node.js release, kept within Node 24 by `.github/scripts/upgrade-deps.ts`. -pub const DEFAULT_NODE_VERSION: &str = "24.22.2"; +/// Embed the repository's Node.js pin so installed binaries need no source checkout. +/// `.github/scripts/upgrade-deps.ts` keeps `.node-version` within Node 24. +pub const DEFAULT_NODE_VERSION: &str = include_str!("../../../.node-version").trim_ascii(); pub use dev_engines::{ nvmrc_version_spans, parse_node_version_content, read_node_version_file, read_nvmrc_file, diff --git a/docs/guide/ci.md b/docs/guide/ci.md index dcb1047890..250c2ebc28 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -14,7 +14,7 @@ Run `vp migrate` to replace exact `voidzero-dev/setup-vp@v1` references in GitHu ## Node.js During Installation -The Vite+ installer uses a pinned Node.js 24 release by default. The dependency-upgrade workflow updates this pin within Node.js 24. 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`. +The Vite+ installer uses the Node.js 24 release embedded from the Vite+ repository's `.node-version` by default. The dependency-upgrade workflow updates this file within Node.js 24. 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. diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 9f4a150cb4..908ce7966f 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -71,7 +71,7 @@ irm https://vite.plus/ps1 | iex On Windows, you can instead download and run [`vp-setup.exe`](https://setup.viteplus.dev). -The installer uses a pinned Node.js 24 release to install its JavaScript dependencies. The dependency-upgrade workflow updates this pin within Node.js 24. CI integrations can select another compatible version through [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). +The installer uses the Node.js 24 release pinned in the Vite+ repository's `.node-version` to install its JavaScript dependencies. This version is embedded in the CLI at build time. Development and CI use the same file, which the dependency-upgrade workflow updates within Node.js 24. CI integrations can select another compatible version through [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). After installation, open a new shell and run: diff --git a/flake.nix b/flake.nix index 14fdbad3dc..68e0764951 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,8 @@ devShells = forAllSystems (pkgs: { default = pkgs.mkShell { packages = with pkgs; [ - nodejs_22 + # Follow the repository's Node.js major; flake.lock pins the Nix package. + pkgs."nodejs_${builtins.head (lib.splitString "." (builtins.readFile ./.node-version))}" pnpm_12 just cmake From 0d6d02d661677d1d46b3dae8544716b29601f6b8 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 16:46:28 +0900 Subject: [PATCH 04/20] fix(runtime): preserve version output and update installer fixtures --- .../fixtures/download_progress/install.mjs | 39 ++++++++++++++++--- crates/vp_global_cli/src/commands/version.rs | 2 +- 2 files changed, 34 insertions(+), 7 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 99d83729f8..1757dfb89e 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 @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { once } from 'node:events'; import { copyFileSync, @@ -29,13 +29,19 @@ 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. +const nodeBin = join(external, 'node-bin'); +for (const directory of [home, user, nodeBin]) mkdirSync(directory, { recursive: true }); +// Expose only Node so the host's npm and npx do not change shim preferences. symlinkSync(process.execPath, join(nodeBin, 'node')); copyFileSync(join(process.env.VP_HOME, 'bin/vp'), join(external, 'vp')); const lockfile = "lockfileVersion: '9.0'\n"; writeFileSync(join(external, 'vp-install-lock.yaml'), lockfile); +const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime; +const platform = `${process.platform}-${process.arch}${musl ? '-musl' : ''}`; +const nodeArchives = new Map(); +let runtimeDownloads = 0; +let checksumRequests = 0; +let indexRequests = 0; const pnpmArchive = createArchive({ 'package/package.json': JSON.stringify({ @@ -76,9 +82,27 @@ async function checkpoint(phase) { /** @returns {Promise} */ async function handleRequest(request, response) { - if (request.url === '/index.json') { + const checksum = /^\/v(\d+\.\d+\.\d+)\/SHASUMS256\.txt$/.exec(request.url); + if (checksum) { + checksumRequests++; + const version = checksum[1]; + const nodeRoot = `node-v${version}-${platform}`; + // Serve the exact default selected by the installer. The tiny Unix wrapper + // runs the mock pnpm with the runner's real Node, without an external download. + const executable = `'${process.execPath.replaceAll("'", "'\\''")}'`; + const archive = createArchive({ + [`${nodeRoot}/bin/node`]: `#!/bin/sh\nexec ${executable} "$@"\n`, + }); + nodeArchives.set(`/v${version}/${nodeRoot}.tar.gz`, archive); await checkpoint('prepare'); - response.end(JSON.stringify([{ version: 'v99.0.0', lts: 'Fixture' }])); + response.end(`${createHash('sha256').update(archive).digest('hex')} ${nodeRoot}.tar.gz\n`); + } else if (nodeArchives.has(request.url)) { + runtimeDownloads++; + response.end(nodeArchives.get(request.url)); + } else if (request.url === '/index.json') { + // A pinned default must not resolve the latest LTS alias. + indexRequests++; + response.writeHead(404).end(); } else if (/^\/pnpm\/-\/pnpm-[\d.]+\.tgz$/.test(request.url)) { await checkpoint('download'); response.setHeader('Content-Length', pnpmArchive.length); @@ -188,6 +212,9 @@ try { ? ['download', 'prepare', 'dependencies', 'dependencies'] : ['download', 'prepare', 'dependencies'], ); + assert.equal(indexRequests, 0); + assert.equal(checksumRequests, 1); + assert.equal(runtimeDownloads, 1); if (failure) { assert.equal(stdout, ''); const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index c35976392e..932d8915ea 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -216,7 +216,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { let node_info = get_node_version_info(&cwd) .await .map(|(v, s)| match s.as_str() { - "lts" | "default" | "system" => format!("v{v}"), + "builtin" | "lts" | "default" | "system" => format!("v{v}"), _ => format!("v{v} ({s})"), }) .unwrap_or(NOT_FOUND.to_string()); From 873efba69b706d32096f16766808ac26c31e0f20 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 17:03:28 +0900 Subject: [PATCH 05/20] fix(runtime): pin shared Node.js version to 24.22.1 --- .node-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.node-version b/.node-version index b74495a07d..ce62013c39 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.22.2 +24.22.1 From 34303b48b7857881fa6dc6a4e966e3dcd26c4636 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 17:32:39 +0900 Subject: [PATCH 06/20] test(cli): reuse prewarmed Node in nvmrc install snapshot --- .../fixtures/command_env_install_nvmrc/snapshots.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml index d40f4bb05c..44d3054f73 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_nvmrc/snapshots.toml @@ -2,7 +2,6 @@ name = "command_env_install_nvmrc" vp = "global" skip-platforms = ["windows"] -seed-runtime = false steps = [ { argv = ["vp", "env", "install"], comment = "Install version from .nvmrc (22.x)", continue-on-failure = true }, ] From 17aa378628ce25a7e339f31006696c6be9353610 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 20:57:04 +0900 Subject: [PATCH 07/20] test(cli): reuse prepared runtimes and prewarm Node selectors --- .github/scripts/prewarm-node.sh | 1 + .github/workflows/ci.yml | 4 ++-- crates/vp_cli_snapshots/tests/cli_snapshots/README.md | 6 ++++-- .../cli_snapshots/fixtures/command_env_clean/snapshots.toml | 2 -- .../fixtures/command_env_install_no_arg_fail/snapshots.toml | 1 - .../fixtures/command_env_list_remote/snapshots.toml | 1 - .../vp_cli_snapshots/tests/cli_snapshots/node-versions.txt | 4 ++++ 7 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/scripts/prewarm-node.sh b/.github/scripts/prewarm-node.sh index fe484ce0e2..8b6a67646f 100644 --- a/.github/scripts/prewarm-node.sh +++ b/.github/scripts/prewarm-node.sh @@ -13,6 +13,7 @@ prewarm_node() { # Development, CI, and the built-in runtime all use this pin. prewarm_node "$(< .node-version)" prewarm_node lts +prewarm_node latest while IFS= read -r node_version; do prewarm_node "$node_version" done < crates/vp_cli_snapshots/tests/cli_snapshots/node-versions.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d29ce5dc..f553e404cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1124,7 +1124,7 @@ jobs: - name: Install Global CLI vp run: pnpm bootstrap-cli:ci - # Seed the repository pin, default LTS, and shared fixture versions before + # Seed the repository pin, aliases, and shared fixture selectors before # command timers start. Keep the version list shared across platforms. - name: Prewarm managed runtimes run: bash .github/scripts/prewarm-node.sh "$HOME/.vite-plus/bin/vp" @@ -1246,7 +1246,7 @@ jobs: shell: bash run: pnpm bootstrap-cli:ci - # Seed the repository pin, default LTS, and shared fixture versions before + # Seed the repository pin, aliases, and shared fixture selectors before # command timers start. Keep the version list shared across platforms. - name: Prewarm managed runtimes shell: bash diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md index 5fe40a3e15..495d394563 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md @@ -387,8 +387,10 @@ cases that do not need a different major. | 24 | `24.22.1` | `24.3.0` is the unsupported migration input; `24.11.0` is the exact supported upgrade target. | | 26 | `26.12.1` | None; script, bare config-file argument, and executable packaging cases share this version. | -CI prepares [`node-versions.txt`](node-versions.txt), the repository's -`.node-version`, and the default LTS before snapshot command timers start. +CI prepares the exact versions and selectors in [`node-versions.txt`](node-versions.txt), +the repository's `.node-version`, and both `lts` and `latest` before snapshot +command timers start. The selectors `20`, `22`, `22.23`, and `24.22` cover cases +that intentionally resolve a major or minor version instead of an exact pin. When updating the defaults, check the [Node.js release index](https://nodejs.org/dist/index.json) and update the fixture pins and shared list together. Document the test reason when adding an exception. Version strings in configuration-only tests do not diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index bd0f73a50c..baf7b985af 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -13,8 +13,6 @@ steps = [ name = "env_clean_preserves_concrete_package_manager_fallback" vp = "global" comment = "System-first changes dispatch preference, not cache ownership; clean must retain the managed fallback used when no system manager is available." -local-registry = true -seed-runtime = false steps = [ { argv = ["node", "prepare-pnpm-versions.cjs"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 8b99a89c33..b12fe6cd79 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -12,7 +12,6 @@ name = "command_env_install_no_node_with_package_manager" vp = "global" comment = "Node.js and package-manager installation are independent components: a missing Node pin must not skip the declared package manager." skip-platforms = ["windows"] -seed-runtime = false steps = [ { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index 6a226fef08..82013cf819 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -33,7 +33,6 @@ steps = [ name = "command_env_list_remote_closed_pipe" vp = "global" skip-platforms = ["windows"] -seed-runtime = false steps = [ { argv = ["vpt", "head-lines", "1", "--", "vp", "env", "list-remote", "--lts"], tty = false, comment = "Closing stdout after the heading does not make remote version listing fail" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/node-versions.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/node-versions.txt index 80a9b3eb9d..2b7169ce4f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/node-versions.txt +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/node-versions.txt @@ -1,7 +1,11 @@ +20 20.20.2 +22 22.18.0 +22.23 22.23.4 24.3.0 24.11.0 +24.22 24.22.1 26.12.1 From bc58eeb972902993f25ee3d65fda14fbeabd0abe Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 21:02:12 +0900 Subject: [PATCH 08/20] docs(ci): link to the shared Node.js version pin --- docs/guide/ci.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/ci.md b/docs/guide/ci.md index 250c2ebc28..296be8b447 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -14,11 +14,11 @@ Run `vp migrate` to replace exact `voidzero-dev/setup-vp@v1` references in GitHu ## Node.js During Installation -The Vite+ installer uses the Node.js 24 release embedded from the Vite+ repository's `.node-version` by default. The dependency-upgrade workflow updates this file within Node.js 24. 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`. +The Vite+ installer uses the Node.js version pinned in the repository's [`.node-version`](https://github.com/voidzero-dev/vite-plus/blob/main/.node-version) by default. Each Vite+ release embeds this version at build time. The dependency-upgrade workflow keeps this pin up to date. 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. +Without a selection, with Node.js management disabled, or when the selected version is incompatible, installation uses the embedded default. 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. From f62ead48143a2d0dcfa861b50392af6bf07edbbf Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 21:22:37 +0900 Subject: [PATCH 09/20] style(deps): simplify the Node.js pin comment --- .github/scripts/upgrade-deps.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 205ed54e30..2fa62f6527 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -423,7 +423,7 @@ async function updateVitestVersionConstant(vitestVersion: string): Promise console.log('Updated packages/cli/src/utils/constants.ts'); } -// ============ Update the development, CI, and default Node.js version ============ +// Update the shared Node.js pin for development, CI, and installation. function updateDefaultNodeVersion(nodeVersion: string): void { const filePath = path.join(ROOT, '.node-version'); const oldVersion = fs.readFileSync(filePath, 'utf8').trim(); From b4f69c4b017b0b9d1c331ec9befbd74e1b481848 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 21:28:47 +0900 Subject: [PATCH 10/20] docs(runtime): describe the embedded Node.js default consistently --- docs/guide/env.md | 4 ++-- docs/guide/global-cli.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index a9f54b3221..b509f19de1 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -39,9 +39,9 @@ To select the project's Node.js version, Vite+ checks the current directory firs 3. `engines.node` in `package.json` 4. `.nvmrc` file -If no directory declares a version, Vite+ uses the global default (`vp env default`) and then its pinned Node.js 24 release. Dependency upgrades keep this pin current within Node.js 24; a new LTS major does not change the fallback. +If no directory declares a version, Vite+ uses the global default (`vp env default`). If no global default is set, it uses the Node.js version pinned in the Vite+ repository's [`.node-version`](https://github.com/voidzero-dev/vite-plus/blob/main/.node-version). Each Vite+ release embeds this pin at build time, so new Node.js releases do not change its fallback. -These rules select Node.js for project commands. The installer uses the same pinned Node.js 24 release 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. +These rules select Node.js for project commands. The installer uses the same embedded 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. diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 908ce7966f..f921cf8788 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -71,7 +71,7 @@ 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 Node.js 24 release pinned in the Vite+ repository's `.node-version` to install its JavaScript dependencies. This version is embedded in the CLI at build time. Development and CI use the same file, which the dependency-upgrade workflow updates within Node.js 24. CI integrations can select another compatible version through [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). +The installer uses the Node.js version pinned in the Vite+ repository's [`.node-version`](https://github.com/voidzero-dev/vite-plus/blob/main/.node-version) to install its JavaScript dependencies. Each Vite+ release embeds this version at build time. Development and CI use the same file, which the dependency-upgrade workflow keeps up to date. CI integrations can select another compatible version through [`VP_SETUP_NODE_VERSION`](#vp-setup-node-version). After installation, open a new shell and run: @@ -111,14 +111,14 @@ These variables control the installer scripts and the standalone Windows install - **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 pinned Node.js 24 release). 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 the pinned Node.js 24 release. 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` also uses the pinned Node.js 24 release. Legacy installers use the runtime policy of the selected older CLI version. +- **Default**: Unset or empty (use the embedded Node.js default). 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 the embedded default. 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` also uses the embedded Node.js default. Legacy installers use the runtime policy of the selected older CLI version. #### `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 the pinned Node.js 24 release for installation, the file still contains the requested version's resolution. +- **Contents**: The resolved version, such as `22.23.1`, written after successful dependency installation. If compatibility requires the embedded default 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. From e1ac739c915ea6048fc6d5220f5d8a923de2583c Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 22:25:52 +0900 Subject: [PATCH 11/20] fix(upgrade): prepare dependencies with the target binary --- .../command_upgrade_prepare/archive.mjs | 19 + .../command_upgrade_prepare/snapshots.toml | 24 ++ .../snapshots/upgrade_prepare_dependencies.md | 10 + .../snapshots/upgrade_prepare_failure.md | 10 + .../snapshots/upgrade_prepare_handoff.md | 10 + .../upgrade_prepare_handoff_failure.md | 11 + .../snapshots/upgrade_prepare_protocol.md | 9 + .../command_upgrade_prepare/verify.mjs | 303 ++++++++++++++++ .../vp_global_cli/src/commands/upgrade/mod.rs | 13 +- .../src/commands/upgrade/preparation.rs | 328 ++++++++++++++++++ crates/vp_global_cli/src/main.rs | 14 +- crates/vp_global_cli/src/upgrade_prepare.rs | 101 ++++++ crates/vp_setup/src/install.rs | 4 +- crates/vp_setup/src/lib.rs | 1 + crates/vp_setup/src/upgrade.rs | 28 ++ crates/vp_shared/src/env_vars.rs | 2 +- docs/guide/global-cli.md | 2 +- docs/guide/upgrade.md | 4 + 18 files changed, 883 insertions(+), 10 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs create mode 100644 crates/vp_global_cli/src/commands/upgrade/preparation.rs create mode 100644 crates/vp_global_cli/src/upgrade_prepare.rs create mode 100644 crates/vp_setup/src/upgrade.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs new file mode 100644 index 0000000000..40038d0b50 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs @@ -0,0 +1,19 @@ +import { gzipSync } from 'node:zlib'; + +// A local platform archive exercises the real upgrade download and integrity checks. +export function createArchive(files) { + const blocks = []; + for (const [name, contents] of Object.entries(files)) { + const body = Buffer.from(contents); + const header = Buffer.alloc(512); + header.write(name); + header.write('0000755\0', 100); + header.write(body.length.toString(8).padStart(11, '0') + '\0', 124); + header.fill(' ', 148, 156); + header.write('0', 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); + blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); + } + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml new file mode 100644 index 0000000000..ce95cf8910 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml @@ -0,0 +1,24 @@ +[[case]] +name = "upgrade_prepare_protocol" +vp = "global" +steps = [{ argv = ["node", "verify.mjs", "protocol"], tty = false }] + +[[case]] +name = "upgrade_prepare_dependencies" +vp = "global" +steps = [{ argv = ["node", "verify.mjs", "dependencies"], tty = false }] + +[[case]] +name = "upgrade_prepare_failure" +vp = "global" +steps = [{ argv = ["node", "verify.mjs", "failure"], tty = false }] + +[[case]] +name = "upgrade_prepare_handoff" +vp = "global" +steps = [{ argv = ["node", "verify.mjs", "upgrade"], tty = false }] + +[[case]] +name = "upgrade_prepare_handoff_failure" +vp = "global" +steps = [{ argv = ["node", "verify.mjs", "upgrade-failure"], tty = false }] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md new file mode 100644 index 0000000000..4e8c148294 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md @@ -0,0 +1,10 @@ +# upgrade_prepare_dependencies + +## `node verify.mjs dependencies` + +``` +Preparation uses the target Node and pnpm pins despite initial-install overrides. +Current, rollback history, preferences, shell files, and setup marker are unchanged. +Post-activation setup reuses the receipt without installing dependencies again. +No registry or Node mirror requests were made. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md new file mode 100644 index 0000000000..8848400eb1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md @@ -0,0 +1,10 @@ +# upgrade_prepare_failure + +## `node verify.mjs failure` + +``` +Preparation uses the target Node and pnpm pins despite initial-install overrides. +Current, rollback history, preferences, shell files, and setup marker are unchanged. +Failed preparation records both output streams and leaves no completion receipt. +No registry or Node mirror requests were made. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md new file mode 100644 index 0000000000..a7452e6d5b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md @@ -0,0 +1,10 @@ +# upgrade_prepare_handoff + +## `node verify.mjs upgrade` + +``` +Preparation uses the target Node and pnpm pins despite initial-install overrides. +The target process prepares a separate version before forced-upgrade activation. +Post-activation setup reuses the receipt without installing dependencies again. +All release requests stayed local; no runtime downloads occurred. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md new file mode 100644 index 0000000000..3e487e26e5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md @@ -0,0 +1,11 @@ +# upgrade_prepare_handoff_failure + +## `node verify.mjs upgrade-failure` + +``` +Preparation uses the target Node and pnpm pins despite initial-install overrides. +Current, rollback history, preferences, shell files, and setup marker are unchanged. +Failed preparation records both output streams and leaves no completion receipt. +Upgrade removes the failed staged version and preserves the active version. +All release requests stayed local; no runtime downloads occurred. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md new file mode 100644 index 0000000000..66b774065d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md @@ -0,0 +1,9 @@ +# upgrade_prepare_protocol + +## `node verify.mjs protocol` + +``` +Both capability probes return their exact protocol without setup. +Unsupported preparation requests fail before installation or activation. +No registry or Node mirror requests were made. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs new file mode 100644 index 0000000000..42d766483d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { createArchive } from './archive.mjs'; + +const exec = promisify(execFile); +const mode = process.argv[2]; +const upgrading = mode.startsWith('upgrade'); +const failure = mode.endsWith('failure'); +const windows = process.platform === 'win32'; +const binaryName = windows ? 'vp.exe' : 'vp'; +const runnerHome = process.env.VP_HOME; +const cli = realpathSync(join(runnerHome, 'current/node_modules/vite-plus')); +const root = resolve(cli, '../..'); +// Optional binaries and target pin also allow a local run with two distinct release builds. +const sourceBinary = process.argv[3] ?? join(runnerHome, 'current/bin', binaryName); +const targetBinary = process.argv[4] ?? join(runnerHome, 'current/bin', binaryName); +const nodeVersion = process.argv[5] ?? readFileSync(join(root, '.node-version'), 'utf8').trim(); +const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version; +const pnpmVersion = /export const INSTALL_PNPM_VERSION = '([^']+)';/.exec( + readFileSync(join(cli, 'generate-install-lockfile.ts'), 'utf8'), +)[1]; +const home = resolve('upgrade-home'); +const user = resolve('upgrade-user'); +const old = join(home, upgrading ? version : 'old-version'); +const target = join(home, upgrading ? 'payload' : version); +const binary = join(target, 'bin', binaryName); +const lockfile = "lockfileVersion: '9.0'\n"; +const retained = new Map([ + [join(home, '.previous-version'), 'rollback-sentinel'], + [join(home, 'config.json'), '{"nodeShimMode":"system_first"}'], + [join(home, 'env'), '# Existing environment\n'], + [join(user, '.bashrc'), '# Existing shell configuration\n'], +]); + +function write(file, content) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +for (const [file, content] of retained) write(file, content); +write(join(old, 'bin', binaryName), 'The current binary must not be replaced.\n'); +mkdirSync(join(target, 'bin'), { recursive: true }); +copyFileSync(targetBinary, binary); +if (windows) { + copyFileSync(join(runnerHome, 'current/bin/vp-shim.exe'), join(target, 'bin/vp-shim.exe')); +} +write(join(target, 'bin/vp-install-lock.yaml'), lockfile); +symlinkSync(old, join(home, 'current'), windows ? 'junction' : 'dir'); + +// Reject unexpected lookups locally: this fixture must use only its private cache. +const requests = []; +let upgradeArchive; +let registry; +if (upgrading) { + copyFileSync(sourceBinary, join(old, 'bin', binaryName)); + write(join(old, 'bin/.vp-setup-complete'), ''); + write(join(old, 'package.json'), JSON.stringify({ name: 'vp-global', version })); + const payload = { + 'package/package.json': JSON.stringify({ version, vpInstallLockfile: true }), + [`package/${binaryName}`]: readFileSync(binary), + 'package/vp-install-lock.yaml': lockfile, + }; + if (windows) payload['package/vp-shim.exe'] = readFileSync(join(target, 'bin/vp-shim.exe')); + upgradeArchive = createArchive(payload); +} +const server = createServer((request, response) => { + if (upgrading && request.url === '/platform.tgz') { + response.end(upgradeArchive); + return; + } + if ( + upgrading && + (request.url === `/vite-plus/${version}` || + request.url.startsWith('/@voidzero-dev/vite-plus-cli-')) + ) { + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + version, + vpInstallLockfile: true, + dist: { + tarball: `${registry}/platform.tgz`, + integrity: `sha512-${createHash('sha512').update(upgradeArchive).digest('base64')}`, + attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + }, + }), + ); + return; + } + requests.push(request.url); + response.writeHead(500).end('Unexpected network request'); +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +registry = `http://127.0.0.1:${server.address().port}`; +const env = { + ...process.env, + HOME: user, + USERPROFILE: user, + VP_HOME: home, + VP_NODE_DIST_MIRROR: registry, + NPM_CONFIG_REGISTRY: registry, + npm_config_registry: registry, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NO_UPDATE_CHECK: '1', + CI: '1', + NO_COLOR: '1', +}; +for (const name of [ + 'VP_UPGRADE_PREPARE', + 'VP_SELF_SETUP_SUPPORT_CHECK', + 'VP_SELF_SETUP_SHELL', + 'VP_SELF_SETUP_REPLACE_EXISTING', + 'VP_DATA_DIR', + 'VP_CACHE_DIR', + 'VP_BIN_DIR', + 'VP_SKIP_DEPS_INSTALL', + 'VP_NODE_VERSION', + 'VP_SETUP_NODE_VERSION', + 'VP_SETUP_NODE_VERSION_FILE', +]) { + delete env[name]; +} + +async function run(args, additions = {}, executable = binary) { + const pending = exec(executable, args, { + env: { ...env, ...additions }, + timeout: 30000, + maxBuffer: 1024 * 1024, + }); + try { + const result = await pending; + return { ...result, code: 0, pid: pending.child.pid }; + } catch (error) { + assert.equal(error.signal, null, error.message); + assert.equal(typeof error.code, 'number', error.message); + return { ...error, pid: pending.child.pid }; + } +} + +function assertUnchanged() { + assert.equal(realpathSync(join(home, 'current')), realpathSync(old)); + for (const [file, content] of retained) assert.equal(readFileSync(file, 'utf8'), content); + assert.equal(existsSync(join(target, 'bin/.vp-setup-complete')), false); + assert.equal(existsSync(join(home, 'bin')), false); + assert.equal(existsSync(join(user, '.zshenv')), false); + assert.equal(existsSync(join(home, '.vp-owned')), false); +} + +try { + if (mode === 'protocol') { + for (const [request, response] of [ + ['1', 'vite-plus-self-setup-v1\n'], + ['upgrade-prepare-v1', 'vite-plus-upgrade-prepare-v1\n'], + ]) { + const result = await run(['--help'], { VP_SELF_SETUP_SUPPORT_CHECK: request }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, response); + assert.equal(result.stderr, ''); + assertUnchanged(); + } + console.log('Both capability probes return their exact protocol without setup.'); + for (const [args, protocol] of [ + [[], 'unsupported'], + [['--help'], 'upgrade-prepare-v1'], + ]) { + const result = await run(args, { VP_UPGRADE_PREPARE: protocol }); + assert.equal(result.code, 1, result.stderr); + assertUnchanged(); + assert.equal(existsSync(join(target, 'package.json')), false); + } + console.log('Unsupported preparation requests fail before installation or activation.'); + } else { + const nodePath = windows ? 'node.exe' : 'bin/node'; + const cachedNode = join(home, 'js_runtime/node', nodeVersion, nodePath); + mkdirSync(dirname(cachedNode), { recursive: true }); + copyFileSync(join(runnerHome, 'js_runtime/node', nodeVersion, nodePath), cachedNode); + const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm'); + write( + join(pnpm, 'package.json'), + JSON.stringify({ version: pnpmVersion, engines: { node: '*' } }), + ); + for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) write(join(pnpm, 'bin', shim), 'unused'); + write( + join(pnpm, 'bin/pnpm.cjs'), + `const assert = require('node:assert/strict'); +const fs = require('node:fs'); +assert.equal(fs.realpathSync(process.execPath), fs.realpathSync(${JSON.stringify(cachedNode)})); +assert.equal(process.version, ${JSON.stringify(`v${nodeVersion}`)}); +assert.equal(process.env.VP_UPGRADE_PREPARE, undefined); +assert.equal(fs.realpathSync(${JSON.stringify(join(home, 'current'))}), fs.realpathSync(${JSON.stringify(old)})); +assert.equal(fs.readFileSync(${JSON.stringify(join(home, '.previous-version'))}, 'utf8'), 'rollback-sentinel'); +assert.equal(process.env.npm_config_registry, ${JSON.stringify(registry)}); +if (${upgrading}) assert.equal(process.env.NPM_CONFIG_REGISTRY, ${JSON.stringify(registry)}); +assert.equal(process.argv.includes('--registry'), false); +assert.ok(process.argv.includes('--frozen-lockfile')); +assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); +const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')); +assert.equal(manifest.dependencies['vite-plus'], ${JSON.stringify(version)}); +assert.equal(manifest.packageManager, ${JSON.stringify(`pnpm@${pnpmVersion}`)}); +fs.appendFileSync('install-count', ${JSON.stringify('installed\n')}); +fs.appendFileSync(${JSON.stringify(join(home, 'install-attempts'))}, JSON.stringify({ parent: process.ppid, directory: process.cwd() }) + ${JSON.stringify('\n')}); +fs.mkdirSync('node_modules/vite-plus', { recursive: true }); +fs.writeFileSync('node_modules/vite-plus/package.json', JSON.stringify({name: 'vite-plus', version: ${JSON.stringify(version)}})); +console.log('Fixture pnpm stdout'); +console.error('Fixture pnpm stderr'); +if (${failure}) process.exit(17); +`, + ); + const overrides = { + VP_NODE_VERSION: '20.18.0', + VP_SETUP_NODE_VERSION: '20.18.0', + VP_SETUP_NODE_VERSION_FILE: join(home, 'selected-node'), + VP_SKIP_DEPS_INSTALL: '1', + VP_VERSION: 'wrong-initial-install-version', + VP_SELF_SETUP_SHELL: 'sh', + NPM_CONFIG_REGISTRY: `${registry}/wrong-uppercase-registry`, + }; + // Initial-install shell mode intercepts normal commands before the upgrade dispatcher. + if (upgrading) delete overrides.VP_SELF_SETUP_SHELL; + const result = upgrading + ? await run( + ['upgrade', version, '--force', '--silent', '--registry', registry], + overrides, + join(old, 'bin', binaryName), + ) + : await run(['--silent'], { ...overrides, VP_UPGRADE_PREPARE: 'upgrade-prepare-v1' }); + assert.equal(result.code, failure ? 1 : 0, result.stderr); + if (!upgrading) assert.equal(result.stdout, ''); + if (!failure) assert.equal(result.stderr, ''); + const attempts = readFileSync(join(home, 'install-attempts'), 'utf8') + .trim() + .split('\n') + .map(JSON.parse); + assert.equal(attempts.length, 1); + const installed = attempts[0].directory; + assert.equal(existsSync(join(home, 'selected-node')), false); + console.log('Preparation uses the target Node and pnpm pins despite initial-install overrides.'); + if (!upgrading || failure) { + assertUnchanged(); + console.log('Current, rollback history, preferences, shell files, and setup marker are unchanged.'); + } + const receipt = join(installed, 'node_modules/.vp-install-complete'); + if (failure) { + assert.equal(existsSync(receipt), false); + const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); + assert.ok(log.includes('Fixture pnpm stdout')); + assert.ok(log.includes('Fixture pnpm stderr')); + console.log('Failed preparation records both output streams and leaves no completion receipt.'); + if (upgrading) { + assert.equal(existsSync(installed), false); + console.log('Upgrade removes the failed staged version and preserves the active version.'); + } + } else { + assert.deepEqual(readFileSync(receipt), createHash('sha256').update(lockfile).digest()); + if (upgrading) { + assert.notEqual(attempts[0].parent, result.pid); + assert.equal(realpathSync(join(home, 'current')), realpathSync(installed)); + assert.equal(readFileSync(join(home, '.previous-version'), 'utf8'), version); + assert.ok(readdirSync(home).some((name) => name.startsWith(`${version}+force.`))); + console.log('The target process prepares a separate version before forced-upgrade activation.'); + } else { + rmSync(join(home, 'current')); + symlinkSync(target, join(home, 'current'), windows ? 'junction' : 'dir'); + const refreshed = await run(['env', 'setup', '--refresh']); + assert.equal(refreshed.code, 0, refreshed.stderr); + assert.equal(readFileSync(join(home, '.previous-version'), 'utf8'), 'rollback-sentinel'); + } + assert.equal(existsSync(join(installed, 'bin/.vp-setup-complete')), true); + assert.equal(readFileSync(join(installed, 'install-count'), 'utf8'), 'installed\n'); + console.log('Post-activation setup reuses the receipt without installing dependencies again.'); + } + } + assert.deepEqual(requests, []); + console.log( + upgrading + ? 'All release requests stayed local; no runtime downloads occurred.' + : 'No registry or Node mirror requests were made.', + ); +} finally { + server.close(); + server.closeAllConnections(); + for (const directory of [home, user]) { + rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +} diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index f2b2540090..db6b724af7 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -3,6 +3,8 @@ //! Downloads and installs a new version of the CLI from the npm registry //! with SHA-512 integrity verification. +mod preparation; + use std::process::ExitStatus; use console::style; @@ -227,11 +229,12 @@ async fn install_platform_and_main( )); } - // Generate wrapper package.json that declares vite-plus as a dependency - 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, None).await?; + // Let the target release choose its Node.js, pnpm, and installation policy. + // Releases without this protocol retain the legacy installation path. + if !preparation::prepare(&binary_path, silent, registry).await? { + install::generate_wrapper_package_json(version_dir, new_version).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/commands/upgrade/preparation.rs b/crates/vp_global_cli/src/commands/upgrade/preparation.rs new file mode 100644 index 0000000000..2a866afcfb --- /dev/null +++ b/crates/vp_global_cli/src/commands/upgrade/preparation.rs @@ -0,0 +1,328 @@ +//! Prepare a downloaded release before the running CLI activates it. + +use std::process::Stdio; + +use tokio::process::Command; +use vp_setup::upgrade::{PREPARE_CAPABILITY, PREPARE_ENV, PREPARE_PROTOCOL}; +use vp_shared::{EnvConfig, VpDirsLayout, env_vars, output}; +use vt_path::AbsolutePath; + +use crate::error::Error; + +/// Return false only when the target does not advertise preparation support. +pub(super) async fn prepare( + binary: &AbsolutePath, + silent: bool, + registry: Option<&str>, +) -> Result { + // Existing self-setup binaries recognize this variable before any setup. + // A new variable alone would let their ordinary --help invocation install them. + let probe = command(binary, registry) + .arg("--help") + .env(env_vars::VP_SELF_SETUP_SUPPORT_CHECK, PREPARE_PROTOCOL) + .output() + .await?; + if !probe.status.success() { + output::print_and_flush( + &mut std::io::stderr().lock(), + &String::from_utf8_lossy(&probe.stderr), + ); + return Err(Error::Upgrade( + format!( + "Failed to probe the downloaded Vite+ binary (exit code {}).", + vp_shared::exit_code_from_status(probe.status) + ) + .into(), + )); + } + if probe.stdout != PREPARE_CAPABILITY.as_bytes() { + return Ok(false); + } + + let mut child = command(binary, registry); + child.env(PREPARE_ENV, PREPARE_PROTOCOL); + let status = if silent { + let result = child.arg("--silent").output().await?; + if !result.status.success() { + output::print_and_flush( + &mut std::io::stderr().lock(), + &String::from_utf8_lossy(&result.stdout), + ); + output::print_and_flush( + &mut std::io::stderr().lock(), + &String::from_utf8_lossy(&result.stderr), + ); + } + result.status + } else { + child.stdout(Stdio::inherit()).stderr(Stdio::inherit()).status().await? + }; + if !status.success() { + return Err(Error::Upgrade( + format!( + "The downloaded Vite+ binary could not prepare its dependencies (exit code {}).", + vp_shared::exit_code_from_status(status) + ) + .into(), + )); + } + Ok(true) +} + +fn command(binary: &AbsolutePath, registry: Option<&str>) -> Command { + let mut command = Command::new(binary.as_path()); + command.stdin(Stdio::null()); + for name in [ + PREPARE_ENV, + env_vars::VP_SELF_SETUP_SUPPORT_CHECK, + env_vars::VP_SELF_SETUP_SHELL, + env_vars::VP_SELF_SETUP_REPLACE_EXISTING, + env_vars::VP_SETUP_NODE_VERSION, + env_vars::VP_SETUP_NODE_VERSION_FILE, + env_vars::VP_DUMP_DIRS, + "VP_SKIP_DEPS_INSTALL", + "VP_VERSION", + "VP_COMPLETE", + ] { + command.env_remove(name); + } + + // Pin the resolved installation roots for this handoff, including an + // existing single-root installation selected without an explicit VP_HOME. + let dirs = &EnvConfig::get().dirs; + match dirs.layout() { + VpDirsLayout::SingleRoot => { + command + .env(env_vars::VP_HOME, dirs.data.as_path()) + .env_remove(env_vars::VP_BIN_DIR) + .env_remove(env_vars::VP_DATA_DIR) + .env_remove(env_vars::VP_CACHE_DIR); + } + VpDirsLayout::Split => { + command + .env_remove(env_vars::VP_HOME) + .env(env_vars::VP_BIN_DIR, dirs.bin.as_path()) + .env(env_vars::VP_DATA_DIR, dirs.data.as_path()) + .env(env_vars::VP_CACHE_DIR, dirs.cache.as_path()); + } + } + if let Some(registry) = registry { + command + .env(env_vars::NPM_CONFIG_REGISTRY, registry) + .env(env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); + } + command +} + +#[cfg(test)] +mod environment_tests { + use std::ffi::OsStr; + + use super::*; + + #[test] + fn child_preserves_resolved_roots_for_both_layouts() { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("commands"); + let data = root.path().join("data"); + let cache = root.path().join("cache"); + for home in [None, Some(root.path().as_os_str())] { + EnvConfig::with_vars( + [ + (env_vars::VP_HOME, home), + (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ("HOME", Some(root.path().as_os_str())), + ("USERPROFILE", Some(root.path().as_os_str())), + ], + |config| { + let child = command(&config.dirs.data.join("target/bin/vp"), None); + let envs = child.as_std().get_envs().collect::>(); + let value = |key| { + envs.iter() + .find(|(name, _)| *name == OsStr::new(key)) + .map(|(_, value)| *value) + }; + if home.is_some() { + assert_eq!(value(env_vars::VP_HOME), Some(home)); + for name in + [env_vars::VP_BIN_DIR, env_vars::VP_DATA_DIR, env_vars::VP_CACHE_DIR] + { + assert_eq!(value(name), Some(None)); + } + } else { + assert_eq!(value(env_vars::VP_HOME), Some(None)); + for (name, directory) in [ + (env_vars::VP_BIN_DIR, &config.dirs.bin), + (env_vars::VP_DATA_DIR, &config.dirs.data), + (env_vars::VP_CACHE_DIR, &config.dirs.cache), + ] { + assert_eq!(value(name), Some(Some(directory.as_path().as_os_str()))); + } + } + }, + ); + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use flate2::{Compression, write::GzEncoder}; + use vp_setup::install; + use vt_path::AbsolutePathBuf; + + use super::*; + + const HELPER: &str = r#"#!/bin/sh +if [ -n "$VP_SELF_SETUP_SUPPORT_CHECK" ]; then + test "$VP_SELF_SETUP_SUPPORT_CHECK" = upgrade-prepare-v1 || exit 91 + test "$1" = --help || exit 92 + printf 'vite-plus-upgrade-prepare-v1\n' + exit 0 +fi +if [ -n "$VP_UPGRADE_PREPARE" ]; then + test "$VP_UPGRADE_PREPARE" = upgrade-prepare-v1 || exit 93 + test "$1" = --silent || exit 94 + test "$NPM_CONFIG_REGISTRY" = "$npm_config_registry" || exit 95 + test -z "$VP_SKIP_DEPS_INSTALL$VP_SETUP_NODE_VERSION$VP_SETUP_NODE_VERSION_FILE$VP_SELF_SETUP_SHELL$VP_VERSION$VP_COMPLETE$VP_DUMP_DIRS" || exit 96 + test "$(cat "$VP_HOME/current/version")" = old || exit 97 + test "$(cat "$VP_HOME/.previous-version")" = previous || exit 98 + target=$(dirname "$(dirname "$0")") + printf '%s' "$NPM_CONFIG_REGISTRY" > "$target/prepared" + exit "${TEST_PREPARE_EXIT:-0}" +fi +test "$1 $2 $3" = 'env setup --refresh' || exit 99 +printf refreshed > "$VP_HOME/refreshed" +"#; + + fn archive(script: &str) -> Vec { + let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); + let mut header = tar::Header::new_gnu(); + header.set_size(script.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + archive.append_data(&mut header, "package/vp", script.as_bytes()).unwrap(); + archive.into_inner().unwrap().finish().unwrap() + } + + fn executable(directory: &AbsolutePath, script: &str) -> AbsolutePathBuf { + let binary = directory.join("vp"); + std::fs::write(&binary, script).unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + binary + } + + #[tokio::test] + async fn unsupported_target_uses_only_the_safe_probe() { + EnvConfig::scoped_async(|config| async move { + let binary = executable( + &config.dirs.data, + r#"#!/bin/sh +test "$VP_SELF_SETUP_SUPPORT_CHECK" = upgrade-prepare-v1 || exit 91 +test "$1" = --help || exit 92 +printf 'vite-plus-self-setup-v1\n' +"#, + ); + assert!(!prepare(&binary, true, None).await.unwrap()); + }) + .await; + } + + #[tokio::test] + async fn failed_probe_does_not_fall_back() { + EnvConfig::scoped_async(|config| async move { + let binary = executable(&config.dirs.data, "#!/bin/sh\nexit 42\n"); + let error = prepare(&binary, true, None).await.unwrap_err(); + assert!(error.to_string().contains("Failed to probe")); + assert!(error.to_string().contains("exit code 42")); + }) + .await; + } + + async fn install_with_helper(exit: &str) { + let registry = httpmock::MockServer::start_async().await; + Box::pin(EnvConfig::scoped_async(|config| async move { + let data = &config.dirs.data; + let old = data.join("old"); + let new = data.join("new"); + tokio::fs::create_dir_all(&old).await.unwrap(); + tokio::fs::create_dir_all(&new).await.unwrap(); + tokio::fs::write(old.join("version"), "old").await.unwrap(); + tokio::fs::write(data.join(".previous-version"), "previous").await.unwrap(); + install::swap_current_link(data, "old").await.unwrap(); + + let result = Box::pin(EnvConfig::with_vars_async( + [ + ("TEST_PREPARE_EXIT", Some(exit)), + (env_vars::NPM_CONFIG_REGISTRY, Some("https://invalid.example")), + (env_vars::NPM_CONFIG_REGISTRY_UPPER, Some("https://invalid.example")), + (env_vars::VP_SETUP_NODE_VERSION, Some("1.0.0")), + (env_vars::VP_SETUP_NODE_VERSION_FILE, Some("must-not-write")), + (env_vars::VP_SELF_SETUP_SHELL, Some("sh")), + (env_vars::VP_DUMP_DIRS, Some("1")), + ("VP_SKIP_DEPS_INSTALL", Some("1")), + ("VP_VERSION", Some("wrong-version")), + ("VP_COMPLETE", Some("bash")), + ], + |_| async { + super::super::install_platform_and_main( + &archive(HELPER), + &new, + data, + "new", + "2.0.0", + "1.0.0", + true, + Some(®istry.base_url()), + ) + .await + }, + )) + .await; + + assert_eq!( + tokio::fs::read_to_string(new.join("prepared")).await.unwrap(), + registry.base_url() + ); + assert!( + !new.join("package.json").as_path().exists(), + "parent must not generate the target's wrapper" + ); + if exit == "0" { + result.unwrap(); + assert_eq!(install::read_current_version(data).await.as_deref(), Some("new")); + assert_eq!( + tokio::fs::read_to_string(data.join(".previous-version")).await.unwrap(), + "old" + ); + assert!(data.join("refreshed").as_path().is_file()); + } else { + let error = result.unwrap_err(); + assert!( + error.to_string().contains("could not prepare its dependencies (exit code 42)") + ); + assert_eq!(install::read_current_version(data).await.as_deref(), Some("old")); + assert_eq!( + tokio::fs::read_to_string(data.join(".previous-version")).await.unwrap(), + "previous" + ); + assert!(!data.join("refreshed").as_path().exists()); + } + })) + .await; + } + + #[tokio::test] + async fn successful_target_preparation_precedes_activation() { + Box::pin(install_with_helper("0")).await; + } + + #[tokio::test] + async fn failed_target_preparation_preserves_activation_and_rollback() { + Box::pin(install_with_helper("42")).await; + } +} diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index d72c6533f3..59c7c946e1 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -23,6 +23,7 @@ mod js_executor; mod self_setup; mod shim; mod upgrade_check; +mod upgrade_prepare; use std::{ env, @@ -314,8 +315,8 @@ fn dump_dirs_from_env_config() -> bool { #[tokio::main] async fn main() -> ExitCode { // Probe before tracing, directory resolution, or argument dispatch can emit output. - if env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK).is_some() { - vp_shared::output::print_stdout_line(format_args!("vite-plus-self-setup-v1")); + if let Some(request) = env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK) { + output::print_stdout(format_args!("{}", vp_setup::upgrade::capability_response(&request))); return ExitCode::SUCCESS; } @@ -330,6 +331,15 @@ async fn main() -> ExitCode { // Initialize tracing vp_shared::init_tracing(); + match upgrade_prepare::maybe_run().await { + Ok(Some(code)) => return code, + Ok(None) => {} + Err(error) => { + output::error(&error.to_string()); + return ExitCode::FAILURE; + } + } + // Internal directory queries are also used by the local installer before deployment. if dump_dirs_from_env_config() { return ExitCode::SUCCESS; diff --git a/crates/vp_global_cli/src/upgrade_prepare.rs b/crates/vp_global_cli/src/upgrade_prepare.rs new file mode 100644 index 0000000000..cd91f70002 --- /dev/null +++ b/crates/vp_global_cli/src/upgrade_prepare.rs @@ -0,0 +1,101 @@ +//! Prepare the downloaded release's dependencies without changing the active installation. + +use std::{ffi::OsString, process::ExitCode}; + +use vp_setup::{VP_BINARY_NAME, install, upgrade}; +use vp_shared::EnvConfig; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::error::Error; + +pub(crate) async fn maybe_run() -> Result, Error> { + let Some(protocol) = std::env::var_os(upgrade::PREPARE_ENV) else { + return Ok(None); + }; + if protocol != upgrade::PREPARE_PROTOCOL { + return Err(Error::Other("Unsupported upgrade preparation protocol".into())); + } + let silent = parse_args(std::env::args_os().skip(1))?; + vp_shared::validate_vp_dir_env().map_err(|error| Error::Other(error.to_string().into()))?; + let binary = AbsolutePathBuf::new(std::env::current_exe()?).ok_or(Error::CliBinaryNotFound)?; + let version_dir = staged_version_dir(&binary, &EnvConfig::get().dirs.data)?; + // The target release owns all installation pins and rules. Initial-install + // overrides, project configuration, and skip-deps flags do not apply here. + install::generate_wrapper_package_json(&version_dir, env!("CARGO_PKG_VERSION")).await?; + // The caller exports an explicit registry to both npm environment spellings. + // Otherwise preserve the inherited registry precedence used by npm and pnpm. + install::install_production_deps(&version_dir, None, !silent, None).await?; + Ok(Some(ExitCode::SUCCESS)) +} + +fn parse_args(args: impl Iterator) -> Result { + let mut silent = false; + for arg in args { + if arg == "--silent" && !silent { + silent = true; + } else { + return Err(Error::Other("Upgrade preparation accepts only --silent".into())); + } + } + Ok(silent) +} + +fn staged_version_dir( + binary: &AbsolutePath, + data: &AbsolutePath, +) -> Result { + let binary = dunce::canonicalize(binary)?; + let data = dunce::canonicalize(data)?; + let bin = binary.parent().ok_or(Error::CliBinaryNotFound)?; + let version_dir = bin.parent().ok_or(Error::CliBinaryNotFound)?; + if binary.file_name() != Some(std::ffi::OsStr::new(VP_BINARY_NAME)) + || bin.file_name() != Some(std::ffi::OsStr::new("bin")) + || version_dir.parent() != Some(data.as_path()) + { + return Err(Error::Other( + "Upgrade preparation requires a staged binary in the installation data directory" + .into(), + )); + } + let current = data.join("current").join("bin").join(VP_BINARY_NAME); + if same_file::is_same_file(&binary, ¤t).unwrap_or(false) { + return Err(Error::Other("Cannot prepare an upgrade in the active installation".into())); + } + AbsolutePathBuf::new(version_dir.to_path_buf()).ok_or(Error::CliBinaryNotFound) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preparation_accepts_only_its_own_options() { + assert!(!parse_args([].into_iter()).unwrap()); + assert!(parse_args(["--silent".into()].into_iter()).unwrap()); + for args in [vec!["--help"], vec!["upgrade"], vec!["--silent", "--silent"]] { + assert!(parse_args(args.into_iter().map(Into::into)).is_err()); + } + } + + #[tokio::test] + async fn preparation_requires_an_inactive_staged_binary() { + let root = tempfile::tempdir().unwrap(); + let data = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let staged = data.join("1.4.0+force.test"); + let binary = staged.join("bin").join(VP_BINARY_NAME); + tokio::fs::create_dir_all(binary.parent().unwrap()).await.unwrap(); + tokio::fs::write(&binary, b"target").await.unwrap(); + assert_eq!( + staged_version_dir(&binary, &data).unwrap().as_path(), + dunce::canonicalize(&staged).unwrap(), + ); + + install::swap_current_link(&data, "1.4.0+force.test").await.unwrap(); + assert!(staged_version_dir(&binary, &data).is_err()); + assert!(staged_version_dir(&data.join("current/bin").join(VP_BINARY_NAME), &data).is_err()); + + let external = tempfile::tempdir().unwrap(); + let external = AbsolutePathBuf::new(external.path().to_path_buf()).unwrap(); + assert!(staged_version_dir(&binary, &external).is_err()); + } +} diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 81718f6eeb..b566dc8a2b 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -344,7 +344,9 @@ async fn run_pnpm_install( .args(args) .current_dir(version_dir) .env("CI", "true") - .env("PATH", path); + .env("PATH", path) + // Lifecycle commands must not inherit the upgrade helper's entry point. + .env_remove(crate::upgrade::PREPARE_ENV); if args.contains(&"--frozen-lockfile") { // --config leaves numbers as strings. Environment values get pnpm's numeric parsing. diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index d81316381f..1c39928014 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -21,6 +21,7 @@ pub mod integrity; pub mod lockfile; pub mod platform; pub mod registry; +pub mod upgrade; /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 3; diff --git a/crates/vp_setup/src/upgrade.rs b/crates/vp_setup/src/upgrade.rs new file mode 100644 index 0000000000..6f6190565c --- /dev/null +++ b/crates/vp_setup/src/upgrade.rs @@ -0,0 +1,28 @@ +//! Versioned handoff for preparing an upgrade before activation. + +/// Request value for both the capability probe and the preparation entry point. +pub const PREPARE_PROTOCOL: &str = "upgrade-prepare-v1"; + +/// Exact response to the preparation capability probe. +pub const PREPARE_CAPABILITY: &str = "vite-plus-upgrade-prepare-v1\n"; + +/// Internal entry point that prepares dependencies without activating the release. +pub const PREPARE_ENV: &str = "VP_UPGRADE_PREPARE"; + +/// Preserve the original bootstrap response for existing shell and Windows installers. +pub fn capability_response(request: &std::ffi::OsStr) -> &'static str { + if request == PREPARE_PROTOCOL { PREPARE_CAPABILITY } else { "vite-plus-self-setup-v1\n" } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bootstrap_probe_remains_compatible() { + for request in ["1", "", "unknown"] { + assert_eq!(capability_response(request.as_ref()), "vite-plus-self-setup-v1\n"); + } + assert_eq!(capability_response(PREPARE_PROTOCOL.as_ref()), PREPARE_CAPABILITY); + } +} diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 03c9f6bc43..044c0ae54f 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -246,7 +246,7 @@ pub const VP_INSECURE_TLS: &str = "VP_INSECURE_TLS"; /// do not implement directory resolution again. pub const VP_DUMP_DIRS: &str = "VP_DUMP_DIRS"; -/// Bootstrap capability probe; presence requests only the self-setup contract. +/// Bootstrap capability probe; upgrade preparation uses a versioned request value. pub const VP_SELF_SETUP_SUPPORT_CHECK: &str = "VP_SELF_SETUP_SUPPORT_CHECK"; /// Skip persistent shell/PATH changes during first-start installation. diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index f921cf8788..c5be2b4a1c 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -113,7 +113,7 @@ These variables control the installer scripts and the standalone Windows install - **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 embedded Node.js default). 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 the embedded default. 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` also uses the embedded Node.js default. Legacy installers use the runtime policy of the selected older CLI version. +- **Scope**: Initial dependency installation only. This variable does not set a project pin, session override, or global default. [`vp upgrade`](/guide/upgrade#global-vp) uses the target binary's embedded Node.js default when both versions support the dependency-installation handoff. Older upgrade clients and legacy installers keep their existing runtime policy. #### `VP_SETUP_NODE_VERSION_FILE` diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index 9deb0c8a59..fbb44ca8d9 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -41,6 +41,10 @@ vp upgrade # install a specific version vp upgrade --registry # use a custom npm registry ``` +After downloading and verifying the target release, `vp upgrade` asks its binary to install the JavaScript dependencies. This uses the target release's embedded Node.js default, pinned pnpm version, and installation lockfile. The current version stays active until dependency installation succeeds. + +This handoff requires support in both the running CLI and the target binary. Older upgrade clients and targets without this support keep their existing installation behavior. After activation, commands without a project or user runtime selection use the new release's embedded Node.js default. + ### Automatic Update Checks The global CLI checks for updates in the background during normal terminal use, caching results for 24 hours. When a newer version is available, it prints a notice to stderr without installing it. From a19e79406f250f0084d29848fce69f62da7ef855 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 22:47:25 +0900 Subject: [PATCH 12/20] docs(upgrade): move preparation details into RFCs --- docs/guide/global-cli.md | 2 +- docs/guide/upgrade.md | 4 - rfcs/merge-global-and-local-cli.md | 11 ++- rfcs/upgrade-command.md | 122 ++++++++++++++++++++--------- 4 files changed, 95 insertions(+), 44 deletions(-) diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index c5be2b4a1c..19c133ddf6 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -113,7 +113,7 @@ These variables control the installer scripts and the standalone Windows install - **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 embedded Node.js default). 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 the embedded default. 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`](/guide/upgrade#global-vp) uses the target binary's embedded Node.js default when both versions support the dependency-installation handoff. Older upgrade clients and legacy installers keep their existing runtime policy. +- **Scope**: Initial dependency installation only. This variable does not set a project pin, session override, or global default. [`vp upgrade`](/guide/upgrade#global-vp) does not use this variable. #### `VP_SETUP_NODE_VERSION_FILE` diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index fbb44ca8d9..9deb0c8a59 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -41,10 +41,6 @@ vp upgrade # install a specific version vp upgrade --registry # use a custom npm registry ``` -After downloading and verifying the target release, `vp upgrade` asks its binary to install the JavaScript dependencies. This uses the target release's embedded Node.js default, pinned pnpm version, and installation lockfile. The current version stays active until dependency installation succeeds. - -This handoff requires support in both the running CLI and the target binary. Older upgrade clients and targets without this support keep their existing installation behavior. After activation, commands without a project or user runtime selection use the new release's embedded Node.js default. - ### Automatic Update Checks The global CLI checks for updates in the background during normal terminal use, caching results for 24 hours. When a newer version is available, it prints a notice to stderr without installing it. diff --git a/rfcs/merge-global-and-local-cli.md b/rfcs/merge-global-and-local-cli.md index 020f52f1f5..d7dc9aa986 100644 --- a/rfcs/merge-global-and-local-cli.md +++ b/rfcs/merge-global-and-local-cli.md @@ -99,10 +99,13 @@ This decouples the `vp` binary from vite-plus's internal file layout. `--frozen-lockfile`. `install.ps1` uses the same flow on Windows. - **Upgrade** (`vp upgrade`): - Downloads the CLI platform archive with the binary and lockfile, generates the - wrapper `package.json`, and restores the bundled lockfile before the frozen - dependency install. pnpm downloads `vite-plus` and its dependencies using the - versions and integrity values recorded in that lockfile. + Downloads and verifies the CLI platform archive, then asks a supported target + binary to prepare dependencies before activation. The target generates the + wrapper `package.json` and installs with its own Node.js pin, pnpm pin, and + bundled lockfile. The running binary switches `current` only after preparation + succeeds. Targets without preparation support and older upgrade clients keep + their existing installation path. See the + [preparation protocol](./upgrade-command.md#preparation-protocol). - **Local dev** (`pnpm bootstrap-cli`): Copies `vp` binary, generates wrapper `package.json`, symlinks diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 5ed1501ed6..3feb637dc9 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -150,21 +150,21 @@ Options: The upgrade command uses `vp_global_cli` and the shared `vp_setup` library. It installs the same binary and bundled lockfile as the script installers. - -``` -┌──────────────────────────────────────────────────────┐ -│ vp upgrade │ -├──────────────────────────────────────────────────────┤ -│ 1. Resolve version (registry query) │ -│ 2. Check if already installed │ -│ 3. Download and verify platform archive │ -│ 4. Extract binary + vp-install-lock.yaml │ -│ 5. Generate wrapper and restore pnpm-lock.yaml │ -│ 6. Install dependencies with --frozen-lockfile │ -│ 7. Swap current → {version} │ -│ 8. Refresh shims (non-fatal) │ -│ 9. Cleanup old versions (non-fatal, keep 3) │ -└──────────────────────────────────────────────────────┘ +The running binary controls activation and rollback. The downloaded target +binary prepares its dependencies with its own Node.js pin, pnpm pin, and +installation rules. + +```mermaid +flowchart LR + A["Running vp
Resolve, download, verify, and extract"] --> P{"Target supports
preparation?"} + P -->|Yes| T["Target vp
Prepare dependencies"] + P -->|"No: successful probe"| L["Running vp
Legacy preparation"] + T -->|Success| S["Running vp
Save rollback version
Switch current"] + L -->|Success| S + S --> R["Refresh setup and shims
Clean old versions
Non-fatal"] + P -->|"Probe failed"| F["Stop upgrade
Keep current and rollback record"] + T -->|Failure| F + L -->|Failure| F ``` ### Implementation Flow @@ -257,14 +257,58 @@ Platform detection reuses existing logic from `vp_js_runtime` or mirrors the bas - `uname -m` → arch (x64, arm64) - Linux: detect gnu vs musl libc -#### Step 4: Extract and Install +#### Step 4: Extract and Prepare Dependencies 1. Create the version directory under the data root resolved by `VpDirs`, with a `bin/` subdirectory. 2. Extract the binary and `vp-install-lock.yaml` into `bin/`, along with `vp-shim.exe` on Windows. -3. Generate the wrapper `package.json` with an exact `vite-plus` dependency and the installer's pinned pnpm version. -4. Restore `bin/vp-install-lock.yaml` as `pnpm-lock.yaml` in the version directory. -5. Write the installation's `.npmrc` with `minimum-release-age=0` so new versions are immediately available. -6. Run managed pnpm with `install --ignore-workspace --frozen-lockfile` in the version directory. Activation waits for a successful install. +3. Probe the target binary for the versioned preparation capability. +4. Ask a supported target to prepare its dependencies. Use the legacy parent-side installation only when the probe succeeds without that capability. +5. Wait for successful preparation before recording the rollback version or switching `current`. + +A forced reinstall of the active version uses a separate directory named +`+force..`. This also avoids overwriting the running +executable on Windows. + +##### Preparation Protocol + +The caller runs the downloaded binary with `--help` and +`VP_SELF_SETUP_SUPPORT_CHECK=upgrade-prepare-v1`. A supported target returns +exactly `vite-plus-upgrade-prepare-v1\n` and exits before normal self-setup. +The existing `VP_SELF_SETUP_SUPPORT_CHECK=1` request still returns exactly +`vite-plus-self-setup-v1\n`, so shell and Windows installers keep their contract. +Older self-setup binaries return the original token for the new probe value; +older binaries without self-setup process `--help` normally. + +After a successful capability response, the caller runs the target with +`VP_UPGRADE_PREPARE=upgrade-prepare-v1`. This internal entry point accepts only +an optional `--silent`. It derives its version directory from its executable +path and requires an inactive binary under `//bin/`. +It runs before normal self-setup and does not activate the version, change +management preferences, modify shell files, or write the final setup marker. + +The target generates the wrapper `package.json` using its own +`CARGO_PKG_VERSION` and pinned pnpm version. It uses `DEFAULT_NODE_VERSION`, +embedded from the repository's `.node-version` at build time, for installation. +It restores `bin/vp-install-lock.yaml` as `pnpm-lock.yaml`, writes +`minimum-release-age=0` to the installation's `.npmrc`, and runs managed pnpm +with `install --ignore-workspace --frozen-lockfile`. The existing +`VP_SETUP_SKIP_INSTALL_LOCKFILE=1` bypass remains available. + +The caller pins the resolved `VpDirs` roots in the child environment. An explicit +`--registry` sets both `npm_config_registry` and `NPM_CONFIG_REGISTRY`; otherwise +the existing registry precedence applies. Mirror, proxy, timeout, and lockfile +bypass settings remain available. Initial-install controls such as +`VP_SETUP_NODE_VERSION`, `VP_SETUP_NODE_VERSION_FILE`, and `VP_SKIP_DEPS_INSTALL` +do not control upgrade preparation. The pnpm process does not inherit +`VP_UPGRADE_PREPARE`, so nested commands cannot reenter this helper accidentally. +The helper has no interactive input. Normal runs inherit output streams; +silent runs show captured diagnostics only on failure. + +A failed probe or preparation stops the upgrade and removes the staged version. +The active version and rollback record remain unchanged. A supported target's +installation failure never falls back to the running binary's installation +logic. Already-released upgrade clients retain their original flow because they +do not know this protocol. Missing advertised lockfiles and integrity failures stop installation. Older packages without a companion keep the legacy dependency installation behavior. @@ -273,6 +317,10 @@ recovers it from the verified platform archive before completing a frozen install. See [Global installation lockfile](./merge-global-and-local-cli.md#global-installation-lockfile) for generation, retry, and compatibility details. +Successful preparation writes the normal installation receipt. After activation, +the target's self-setup checks that receipt and avoids a second dependency +installation before refreshing setup and shims. + #### Step 5: Version Swap **Unix (macOS/Linux)** — Atomic symlink swap: @@ -337,10 +385,12 @@ After the `current` link swap, any **new** invocation of `vp` will use the new b The `--rollback` flag switches the `current` symlink to the previously active version. -To track the previous version, we can: +The running binary records the previous version after dependency preparation +succeeds and before activation: 1. Read the `current` symlink target before updating -2. After the update, write the previous version to `~/.vite-plus/.previous-version` +2. Write that version to `/.previous-version` +3. Switch `current` to the prepared version For `--rollback`: @@ -356,7 +406,8 @@ For `--rollback`: | Network failure during download | Clean up partial temp files, exit with helpful message | | Integrity mismatch (SHA-512) | Delete downloaded file, report expected vs actual hash, abort | | Corrupted tarball | Verify extraction success, clean up version dir if partial | -| `vp install` fails | Remove the version dir, keep current version unchanged | +| Capability probe fails | Remove the staged version; keep current and rollback unchanged | +| Dependency preparation fails | Remove the staged version; keep current and rollback unchanged | | Disk full | Detect and report, clean up partial state | | Permission denied | Report with suggestion to check directory ownership | | Registry returns error | Parse npm error JSON, show human-readable message | @@ -366,18 +417,19 @@ Key principle: **The `current` symlink is only swapped after all pre-swap steps ### File Structure ``` -crates/vp_global_cli/ -├── src/ -│ ├── commands/ -│ │ ├── upgrade/ -│ │ │ ├── mod.rs # Module root, public execute() function -│ │ │ ├── registry.rs # npm registry client (version resolution, tarball URLs) -│ │ │ ├── platform.rs # Platform detection (os, arch, libc) -│ │ │ ├── download.rs # HTTP download + tarball extraction -│ │ │ └── install.rs # Extract, dependency install, symlink swap, cleanup -│ │ ├── mod.rs # Add upgrade module -│ │ └── ... -│ └── cli.rs # Add Upgrade command variant +crates/vp_global_cli/src/ +├── commands/upgrade/ +│ ├── mod.rs # Download, activation, rollback, and cleanup +│ └── preparation.rs # Target capability probe and preparation handoff +├── upgrade_prepare.rs # Target entry point before normal self-setup +└── self_setup.rs # Setup and shim refresh after activation + +crates/vp_setup/src/ +├── upgrade.rs # Versioned preparation protocol +├── install.rs # Extraction, dependency installation, and version links +├── lockfile.rs # Release lockfile and installation receipt +├── registry.rs # Release resolution and archive metadata +└── platform.rs # Platform detection ``` ### Platform Detection From dae736440e54573c362e71233df7b25062a41548 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 23:05:41 +0900 Subject: [PATCH 13/20] revert: defer upgrade runtime consistency to Homebrew setup --- .../command_upgrade_prepare/archive.mjs | 19 - .../command_upgrade_prepare/snapshots.toml | 24 -- .../snapshots/upgrade_prepare_dependencies.md | 10 - .../snapshots/upgrade_prepare_failure.md | 10 - .../snapshots/upgrade_prepare_handoff.md | 10 - .../upgrade_prepare_handoff_failure.md | 11 - .../snapshots/upgrade_prepare_protocol.md | 9 - .../command_upgrade_prepare/verify.mjs | 303 ---------------- .../vp_global_cli/src/commands/upgrade/mod.rs | 13 +- .../src/commands/upgrade/preparation.rs | 328 ------------------ crates/vp_global_cli/src/main.rs | 14 +- crates/vp_global_cli/src/upgrade_prepare.rs | 101 ------ crates/vp_setup/src/install.rs | 4 +- crates/vp_setup/src/lib.rs | 1 - crates/vp_setup/src/upgrade.rs | 28 -- crates/vp_shared/src/env_vars.rs | 2 +- rfcs/merge-global-and-local-cli.md | 11 +- rfcs/upgrade-command.md | 122 ++----- 18 files changed, 48 insertions(+), 972 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs delete mode 100644 crates/vp_global_cli/src/commands/upgrade/preparation.rs delete mode 100644 crates/vp_global_cli/src/upgrade_prepare.rs delete mode 100644 crates/vp_setup/src/upgrade.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs deleted file mode 100644 index 40038d0b50..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/archive.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { gzipSync } from 'node:zlib'; - -// A local platform archive exercises the real upgrade download and integrity checks. -export function createArchive(files) { - const blocks = []; - for (const [name, contents] of Object.entries(files)) { - const body = Buffer.from(contents); - const header = Buffer.alloc(512); - header.write(name); - header.write('0000755\0', 100); - header.write(body.length.toString(8).padStart(11, '0') + '\0', 124); - header.fill(' ', 148, 156); - header.write('0', 156); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); - blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); - } - return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); -} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml deleted file mode 100644 index ce95cf8910..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots.toml +++ /dev/null @@ -1,24 +0,0 @@ -[[case]] -name = "upgrade_prepare_protocol" -vp = "global" -steps = [{ argv = ["node", "verify.mjs", "protocol"], tty = false }] - -[[case]] -name = "upgrade_prepare_dependencies" -vp = "global" -steps = [{ argv = ["node", "verify.mjs", "dependencies"], tty = false }] - -[[case]] -name = "upgrade_prepare_failure" -vp = "global" -steps = [{ argv = ["node", "verify.mjs", "failure"], tty = false }] - -[[case]] -name = "upgrade_prepare_handoff" -vp = "global" -steps = [{ argv = ["node", "verify.mjs", "upgrade"], tty = false }] - -[[case]] -name = "upgrade_prepare_handoff_failure" -vp = "global" -steps = [{ argv = ["node", "verify.mjs", "upgrade-failure"], tty = false }] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md deleted file mode 100644 index 4e8c148294..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_dependencies.md +++ /dev/null @@ -1,10 +0,0 @@ -# upgrade_prepare_dependencies - -## `node verify.mjs dependencies` - -``` -Preparation uses the target Node and pnpm pins despite initial-install overrides. -Current, rollback history, preferences, shell files, and setup marker are unchanged. -Post-activation setup reuses the receipt without installing dependencies again. -No registry or Node mirror requests were made. -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md deleted file mode 100644 index 8848400eb1..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_failure.md +++ /dev/null @@ -1,10 +0,0 @@ -# upgrade_prepare_failure - -## `node verify.mjs failure` - -``` -Preparation uses the target Node and pnpm pins despite initial-install overrides. -Current, rollback history, preferences, shell files, and setup marker are unchanged. -Failed preparation records both output streams and leaves no completion receipt. -No registry or Node mirror requests were made. -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md deleted file mode 100644 index a7452e6d5b..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff.md +++ /dev/null @@ -1,10 +0,0 @@ -# upgrade_prepare_handoff - -## `node verify.mjs upgrade` - -``` -Preparation uses the target Node and pnpm pins despite initial-install overrides. -The target process prepares a separate version before forced-upgrade activation. -Post-activation setup reuses the receipt without installing dependencies again. -All release requests stayed local; no runtime downloads occurred. -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md deleted file mode 100644 index 3e487e26e5..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_handoff_failure.md +++ /dev/null @@ -1,11 +0,0 @@ -# upgrade_prepare_handoff_failure - -## `node verify.mjs upgrade-failure` - -``` -Preparation uses the target Node and pnpm pins despite initial-install overrides. -Current, rollback history, preferences, shell files, and setup marker are unchanged. -Failed preparation records both output streams and leaves no completion receipt. -Upgrade removes the failed staged version and preserves the active version. -All release requests stayed local; no runtime downloads occurred. -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md deleted file mode 100644 index 66b774065d..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/snapshots/upgrade_prepare_protocol.md +++ /dev/null @@ -1,9 +0,0 @@ -# upgrade_prepare_protocol - -## `node verify.mjs protocol` - -``` -Both capability probes return their exact protocol without setup. -Unsupported preparation requests fail before installation or activation. -No registry or Node mirror requests were made. -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs deleted file mode 100644 index 42d766483d..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_prepare/verify.mjs +++ /dev/null @@ -1,303 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { once } from 'node:events'; -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { createServer } from 'node:http'; -import { dirname, join, resolve } from 'node:path'; -import { promisify } from 'node:util'; - -import { createArchive } from './archive.mjs'; - -const exec = promisify(execFile); -const mode = process.argv[2]; -const upgrading = mode.startsWith('upgrade'); -const failure = mode.endsWith('failure'); -const windows = process.platform === 'win32'; -const binaryName = windows ? 'vp.exe' : 'vp'; -const runnerHome = process.env.VP_HOME; -const cli = realpathSync(join(runnerHome, 'current/node_modules/vite-plus')); -const root = resolve(cli, '../..'); -// Optional binaries and target pin also allow a local run with two distinct release builds. -const sourceBinary = process.argv[3] ?? join(runnerHome, 'current/bin', binaryName); -const targetBinary = process.argv[4] ?? join(runnerHome, 'current/bin', binaryName); -const nodeVersion = process.argv[5] ?? readFileSync(join(root, '.node-version'), 'utf8').trim(); -const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version; -const pnpmVersion = /export const INSTALL_PNPM_VERSION = '([^']+)';/.exec( - readFileSync(join(cli, 'generate-install-lockfile.ts'), 'utf8'), -)[1]; -const home = resolve('upgrade-home'); -const user = resolve('upgrade-user'); -const old = join(home, upgrading ? version : 'old-version'); -const target = join(home, upgrading ? 'payload' : version); -const binary = join(target, 'bin', binaryName); -const lockfile = "lockfileVersion: '9.0'\n"; -const retained = new Map([ - [join(home, '.previous-version'), 'rollback-sentinel'], - [join(home, 'config.json'), '{"nodeShimMode":"system_first"}'], - [join(home, 'env'), '# Existing environment\n'], - [join(user, '.bashrc'), '# Existing shell configuration\n'], -]); - -function write(file, content) { - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, content); -} - -for (const [file, content] of retained) write(file, content); -write(join(old, 'bin', binaryName), 'The current binary must not be replaced.\n'); -mkdirSync(join(target, 'bin'), { recursive: true }); -copyFileSync(targetBinary, binary); -if (windows) { - copyFileSync(join(runnerHome, 'current/bin/vp-shim.exe'), join(target, 'bin/vp-shim.exe')); -} -write(join(target, 'bin/vp-install-lock.yaml'), lockfile); -symlinkSync(old, join(home, 'current'), windows ? 'junction' : 'dir'); - -// Reject unexpected lookups locally: this fixture must use only its private cache. -const requests = []; -let upgradeArchive; -let registry; -if (upgrading) { - copyFileSync(sourceBinary, join(old, 'bin', binaryName)); - write(join(old, 'bin/.vp-setup-complete'), ''); - write(join(old, 'package.json'), JSON.stringify({ name: 'vp-global', version })); - const payload = { - 'package/package.json': JSON.stringify({ version, vpInstallLockfile: true }), - [`package/${binaryName}`]: readFileSync(binary), - 'package/vp-install-lock.yaml': lockfile, - }; - if (windows) payload['package/vp-shim.exe'] = readFileSync(join(target, 'bin/vp-shim.exe')); - upgradeArchive = createArchive(payload); -} -const server = createServer((request, response) => { - if (upgrading && request.url === '/platform.tgz') { - response.end(upgradeArchive); - return; - } - if ( - upgrading && - (request.url === `/vite-plus/${version}` || - request.url.startsWith('/@voidzero-dev/vite-plus-cli-')) - ) { - response.setHeader('Content-Type', 'application/json'); - response.end( - JSON.stringify({ - version, - vpInstallLockfile: true, - dist: { - tarball: `${registry}/platform.tgz`, - integrity: `sha512-${createHash('sha512').update(upgradeArchive).digest('base64')}`, - attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, - }, - }), - ); - return; - } - requests.push(request.url); - response.writeHead(500).end('Unexpected network request'); -}); -server.listen(0, '127.0.0.1'); -await once(server, 'listening'); -registry = `http://127.0.0.1:${server.address().port}`; -const env = { - ...process.env, - HOME: user, - USERPROFILE: user, - VP_HOME: home, - VP_NODE_DIST_MIRROR: registry, - NPM_CONFIG_REGISTRY: registry, - npm_config_registry: registry, - VP_SELF_SETUP_NO_MODIFY_PATH: '1', - VP_NO_UPDATE_CHECK: '1', - CI: '1', - NO_COLOR: '1', -}; -for (const name of [ - 'VP_UPGRADE_PREPARE', - 'VP_SELF_SETUP_SUPPORT_CHECK', - 'VP_SELF_SETUP_SHELL', - 'VP_SELF_SETUP_REPLACE_EXISTING', - 'VP_DATA_DIR', - 'VP_CACHE_DIR', - 'VP_BIN_DIR', - 'VP_SKIP_DEPS_INSTALL', - 'VP_NODE_VERSION', - 'VP_SETUP_NODE_VERSION', - 'VP_SETUP_NODE_VERSION_FILE', -]) { - delete env[name]; -} - -async function run(args, additions = {}, executable = binary) { - const pending = exec(executable, args, { - env: { ...env, ...additions }, - timeout: 30000, - maxBuffer: 1024 * 1024, - }); - try { - const result = await pending; - return { ...result, code: 0, pid: pending.child.pid }; - } catch (error) { - assert.equal(error.signal, null, error.message); - assert.equal(typeof error.code, 'number', error.message); - return { ...error, pid: pending.child.pid }; - } -} - -function assertUnchanged() { - assert.equal(realpathSync(join(home, 'current')), realpathSync(old)); - for (const [file, content] of retained) assert.equal(readFileSync(file, 'utf8'), content); - assert.equal(existsSync(join(target, 'bin/.vp-setup-complete')), false); - assert.equal(existsSync(join(home, 'bin')), false); - assert.equal(existsSync(join(user, '.zshenv')), false); - assert.equal(existsSync(join(home, '.vp-owned')), false); -} - -try { - if (mode === 'protocol') { - for (const [request, response] of [ - ['1', 'vite-plus-self-setup-v1\n'], - ['upgrade-prepare-v1', 'vite-plus-upgrade-prepare-v1\n'], - ]) { - const result = await run(['--help'], { VP_SELF_SETUP_SUPPORT_CHECK: request }); - assert.equal(result.code, 0, result.stderr); - assert.equal(result.stdout, response); - assert.equal(result.stderr, ''); - assertUnchanged(); - } - console.log('Both capability probes return their exact protocol without setup.'); - for (const [args, protocol] of [ - [[], 'unsupported'], - [['--help'], 'upgrade-prepare-v1'], - ]) { - const result = await run(args, { VP_UPGRADE_PREPARE: protocol }); - assert.equal(result.code, 1, result.stderr); - assertUnchanged(); - assert.equal(existsSync(join(target, 'package.json')), false); - } - console.log('Unsupported preparation requests fail before installation or activation.'); - } else { - const nodePath = windows ? 'node.exe' : 'bin/node'; - const cachedNode = join(home, 'js_runtime/node', nodeVersion, nodePath); - mkdirSync(dirname(cachedNode), { recursive: true }); - copyFileSync(join(runnerHome, 'js_runtime/node', nodeVersion, nodePath), cachedNode); - const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm'); - write( - join(pnpm, 'package.json'), - JSON.stringify({ version: pnpmVersion, engines: { node: '*' } }), - ); - for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) write(join(pnpm, 'bin', shim), 'unused'); - write( - join(pnpm, 'bin/pnpm.cjs'), - `const assert = require('node:assert/strict'); -const fs = require('node:fs'); -assert.equal(fs.realpathSync(process.execPath), fs.realpathSync(${JSON.stringify(cachedNode)})); -assert.equal(process.version, ${JSON.stringify(`v${nodeVersion}`)}); -assert.equal(process.env.VP_UPGRADE_PREPARE, undefined); -assert.equal(fs.realpathSync(${JSON.stringify(join(home, 'current'))}), fs.realpathSync(${JSON.stringify(old)})); -assert.equal(fs.readFileSync(${JSON.stringify(join(home, '.previous-version'))}, 'utf8'), 'rollback-sentinel'); -assert.equal(process.env.npm_config_registry, ${JSON.stringify(registry)}); -if (${upgrading}) assert.equal(process.env.NPM_CONFIG_REGISTRY, ${JSON.stringify(registry)}); -assert.equal(process.argv.includes('--registry'), false); -assert.ok(process.argv.includes('--frozen-lockfile')); -assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); -const manifest = JSON.parse(fs.readFileSync('package.json', 'utf8')); -assert.equal(manifest.dependencies['vite-plus'], ${JSON.stringify(version)}); -assert.equal(manifest.packageManager, ${JSON.stringify(`pnpm@${pnpmVersion}`)}); -fs.appendFileSync('install-count', ${JSON.stringify('installed\n')}); -fs.appendFileSync(${JSON.stringify(join(home, 'install-attempts'))}, JSON.stringify({ parent: process.ppid, directory: process.cwd() }) + ${JSON.stringify('\n')}); -fs.mkdirSync('node_modules/vite-plus', { recursive: true }); -fs.writeFileSync('node_modules/vite-plus/package.json', JSON.stringify({name: 'vite-plus', version: ${JSON.stringify(version)}})); -console.log('Fixture pnpm stdout'); -console.error('Fixture pnpm stderr'); -if (${failure}) process.exit(17); -`, - ); - const overrides = { - VP_NODE_VERSION: '20.18.0', - VP_SETUP_NODE_VERSION: '20.18.0', - VP_SETUP_NODE_VERSION_FILE: join(home, 'selected-node'), - VP_SKIP_DEPS_INSTALL: '1', - VP_VERSION: 'wrong-initial-install-version', - VP_SELF_SETUP_SHELL: 'sh', - NPM_CONFIG_REGISTRY: `${registry}/wrong-uppercase-registry`, - }; - // Initial-install shell mode intercepts normal commands before the upgrade dispatcher. - if (upgrading) delete overrides.VP_SELF_SETUP_SHELL; - const result = upgrading - ? await run( - ['upgrade', version, '--force', '--silent', '--registry', registry], - overrides, - join(old, 'bin', binaryName), - ) - : await run(['--silent'], { ...overrides, VP_UPGRADE_PREPARE: 'upgrade-prepare-v1' }); - assert.equal(result.code, failure ? 1 : 0, result.stderr); - if (!upgrading) assert.equal(result.stdout, ''); - if (!failure) assert.equal(result.stderr, ''); - const attempts = readFileSync(join(home, 'install-attempts'), 'utf8') - .trim() - .split('\n') - .map(JSON.parse); - assert.equal(attempts.length, 1); - const installed = attempts[0].directory; - assert.equal(existsSync(join(home, 'selected-node')), false); - console.log('Preparation uses the target Node and pnpm pins despite initial-install overrides.'); - if (!upgrading || failure) { - assertUnchanged(); - console.log('Current, rollback history, preferences, shell files, and setup marker are unchanged.'); - } - const receipt = join(installed, 'node_modules/.vp-install-complete'); - if (failure) { - assert.equal(existsSync(receipt), false); - const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); - assert.ok(log.includes('Fixture pnpm stdout')); - assert.ok(log.includes('Fixture pnpm stderr')); - console.log('Failed preparation records both output streams and leaves no completion receipt.'); - if (upgrading) { - assert.equal(existsSync(installed), false); - console.log('Upgrade removes the failed staged version and preserves the active version.'); - } - } else { - assert.deepEqual(readFileSync(receipt), createHash('sha256').update(lockfile).digest()); - if (upgrading) { - assert.notEqual(attempts[0].parent, result.pid); - assert.equal(realpathSync(join(home, 'current')), realpathSync(installed)); - assert.equal(readFileSync(join(home, '.previous-version'), 'utf8'), version); - assert.ok(readdirSync(home).some((name) => name.startsWith(`${version}+force.`))); - console.log('The target process prepares a separate version before forced-upgrade activation.'); - } else { - rmSync(join(home, 'current')); - symlinkSync(target, join(home, 'current'), windows ? 'junction' : 'dir'); - const refreshed = await run(['env', 'setup', '--refresh']); - assert.equal(refreshed.code, 0, refreshed.stderr); - assert.equal(readFileSync(join(home, '.previous-version'), 'utf8'), 'rollback-sentinel'); - } - assert.equal(existsSync(join(installed, 'bin/.vp-setup-complete')), true); - assert.equal(readFileSync(join(installed, 'install-count'), 'utf8'), 'installed\n'); - console.log('Post-activation setup reuses the receipt without installing dependencies again.'); - } - } - assert.deepEqual(requests, []); - console.log( - upgrading - ? 'All release requests stayed local; no runtime downloads occurred.' - : 'No registry or Node mirror requests were made.', - ); -} finally { - server.close(); - server.closeAllConnections(); - for (const directory of [home, user]) { - rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); - } -} diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index db6b724af7..f2b2540090 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -3,8 +3,6 @@ //! Downloads and installs a new version of the CLI from the npm registry //! with SHA-512 integrity verification. -mod preparation; - use std::process::ExitStatus; use console::style; @@ -229,12 +227,11 @@ async fn install_platform_and_main( )); } - // Let the target release choose its Node.js, pnpm, and installation policy. - // Releases without this protocol retain the legacy installation path. - if !preparation::prepare(&binary_path, silent, registry).await? { - install::generate_wrapper_package_json(version_dir, new_version).await?; - install::install_production_deps(version_dir, registry, !silent, None).await?; - } + // Generate wrapper package.json that declares vite-plus as a dependency + 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, 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/commands/upgrade/preparation.rs b/crates/vp_global_cli/src/commands/upgrade/preparation.rs deleted file mode 100644 index 2a866afcfb..0000000000 --- a/crates/vp_global_cli/src/commands/upgrade/preparation.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Prepare a downloaded release before the running CLI activates it. - -use std::process::Stdio; - -use tokio::process::Command; -use vp_setup::upgrade::{PREPARE_CAPABILITY, PREPARE_ENV, PREPARE_PROTOCOL}; -use vp_shared::{EnvConfig, VpDirsLayout, env_vars, output}; -use vt_path::AbsolutePath; - -use crate::error::Error; - -/// Return false only when the target does not advertise preparation support. -pub(super) async fn prepare( - binary: &AbsolutePath, - silent: bool, - registry: Option<&str>, -) -> Result { - // Existing self-setup binaries recognize this variable before any setup. - // A new variable alone would let their ordinary --help invocation install them. - let probe = command(binary, registry) - .arg("--help") - .env(env_vars::VP_SELF_SETUP_SUPPORT_CHECK, PREPARE_PROTOCOL) - .output() - .await?; - if !probe.status.success() { - output::print_and_flush( - &mut std::io::stderr().lock(), - &String::from_utf8_lossy(&probe.stderr), - ); - return Err(Error::Upgrade( - format!( - "Failed to probe the downloaded Vite+ binary (exit code {}).", - vp_shared::exit_code_from_status(probe.status) - ) - .into(), - )); - } - if probe.stdout != PREPARE_CAPABILITY.as_bytes() { - return Ok(false); - } - - let mut child = command(binary, registry); - child.env(PREPARE_ENV, PREPARE_PROTOCOL); - let status = if silent { - let result = child.arg("--silent").output().await?; - if !result.status.success() { - output::print_and_flush( - &mut std::io::stderr().lock(), - &String::from_utf8_lossy(&result.stdout), - ); - output::print_and_flush( - &mut std::io::stderr().lock(), - &String::from_utf8_lossy(&result.stderr), - ); - } - result.status - } else { - child.stdout(Stdio::inherit()).stderr(Stdio::inherit()).status().await? - }; - if !status.success() { - return Err(Error::Upgrade( - format!( - "The downloaded Vite+ binary could not prepare its dependencies (exit code {}).", - vp_shared::exit_code_from_status(status) - ) - .into(), - )); - } - Ok(true) -} - -fn command(binary: &AbsolutePath, registry: Option<&str>) -> Command { - let mut command = Command::new(binary.as_path()); - command.stdin(Stdio::null()); - for name in [ - PREPARE_ENV, - env_vars::VP_SELF_SETUP_SUPPORT_CHECK, - env_vars::VP_SELF_SETUP_SHELL, - env_vars::VP_SELF_SETUP_REPLACE_EXISTING, - env_vars::VP_SETUP_NODE_VERSION, - env_vars::VP_SETUP_NODE_VERSION_FILE, - env_vars::VP_DUMP_DIRS, - "VP_SKIP_DEPS_INSTALL", - "VP_VERSION", - "VP_COMPLETE", - ] { - command.env_remove(name); - } - - // Pin the resolved installation roots for this handoff, including an - // existing single-root installation selected without an explicit VP_HOME. - let dirs = &EnvConfig::get().dirs; - match dirs.layout() { - VpDirsLayout::SingleRoot => { - command - .env(env_vars::VP_HOME, dirs.data.as_path()) - .env_remove(env_vars::VP_BIN_DIR) - .env_remove(env_vars::VP_DATA_DIR) - .env_remove(env_vars::VP_CACHE_DIR); - } - VpDirsLayout::Split => { - command - .env_remove(env_vars::VP_HOME) - .env(env_vars::VP_BIN_DIR, dirs.bin.as_path()) - .env(env_vars::VP_DATA_DIR, dirs.data.as_path()) - .env(env_vars::VP_CACHE_DIR, dirs.cache.as_path()); - } - } - if let Some(registry) = registry { - command - .env(env_vars::NPM_CONFIG_REGISTRY, registry) - .env(env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); - } - command -} - -#[cfg(test)] -mod environment_tests { - use std::ffi::OsStr; - - use super::*; - - #[test] - fn child_preserves_resolved_roots_for_both_layouts() { - let root = tempfile::tempdir().unwrap(); - let bin = root.path().join("commands"); - let data = root.path().join("data"); - let cache = root.path().join("cache"); - for home in [None, Some(root.path().as_os_str())] { - EnvConfig::with_vars( - [ - (env_vars::VP_HOME, home), - (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), - (env_vars::VP_DATA_DIR, Some(data.as_os_str())), - (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), - ("HOME", Some(root.path().as_os_str())), - ("USERPROFILE", Some(root.path().as_os_str())), - ], - |config| { - let child = command(&config.dirs.data.join("target/bin/vp"), None); - let envs = child.as_std().get_envs().collect::>(); - let value = |key| { - envs.iter() - .find(|(name, _)| *name == OsStr::new(key)) - .map(|(_, value)| *value) - }; - if home.is_some() { - assert_eq!(value(env_vars::VP_HOME), Some(home)); - for name in - [env_vars::VP_BIN_DIR, env_vars::VP_DATA_DIR, env_vars::VP_CACHE_DIR] - { - assert_eq!(value(name), Some(None)); - } - } else { - assert_eq!(value(env_vars::VP_HOME), Some(None)); - for (name, directory) in [ - (env_vars::VP_BIN_DIR, &config.dirs.bin), - (env_vars::VP_DATA_DIR, &config.dirs.data), - (env_vars::VP_CACHE_DIR, &config.dirs.cache), - ] { - assert_eq!(value(name), Some(Some(directory.as_path().as_os_str()))); - } - } - }, - ); - } - } -} - -#[cfg(all(test, unix))] -mod tests { - use std::os::unix::fs::PermissionsExt; - - use flate2::{Compression, write::GzEncoder}; - use vp_setup::install; - use vt_path::AbsolutePathBuf; - - use super::*; - - const HELPER: &str = r#"#!/bin/sh -if [ -n "$VP_SELF_SETUP_SUPPORT_CHECK" ]; then - test "$VP_SELF_SETUP_SUPPORT_CHECK" = upgrade-prepare-v1 || exit 91 - test "$1" = --help || exit 92 - printf 'vite-plus-upgrade-prepare-v1\n' - exit 0 -fi -if [ -n "$VP_UPGRADE_PREPARE" ]; then - test "$VP_UPGRADE_PREPARE" = upgrade-prepare-v1 || exit 93 - test "$1" = --silent || exit 94 - test "$NPM_CONFIG_REGISTRY" = "$npm_config_registry" || exit 95 - test -z "$VP_SKIP_DEPS_INSTALL$VP_SETUP_NODE_VERSION$VP_SETUP_NODE_VERSION_FILE$VP_SELF_SETUP_SHELL$VP_VERSION$VP_COMPLETE$VP_DUMP_DIRS" || exit 96 - test "$(cat "$VP_HOME/current/version")" = old || exit 97 - test "$(cat "$VP_HOME/.previous-version")" = previous || exit 98 - target=$(dirname "$(dirname "$0")") - printf '%s' "$NPM_CONFIG_REGISTRY" > "$target/prepared" - exit "${TEST_PREPARE_EXIT:-0}" -fi -test "$1 $2 $3" = 'env setup --refresh' || exit 99 -printf refreshed > "$VP_HOME/refreshed" -"#; - - fn archive(script: &str) -> Vec { - let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); - let mut header = tar::Header::new_gnu(); - header.set_size(script.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - archive.append_data(&mut header, "package/vp", script.as_bytes()).unwrap(); - archive.into_inner().unwrap().finish().unwrap() - } - - fn executable(directory: &AbsolutePath, script: &str) -> AbsolutePathBuf { - let binary = directory.join("vp"); - std::fs::write(&binary, script).unwrap(); - std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); - binary - } - - #[tokio::test] - async fn unsupported_target_uses_only_the_safe_probe() { - EnvConfig::scoped_async(|config| async move { - let binary = executable( - &config.dirs.data, - r#"#!/bin/sh -test "$VP_SELF_SETUP_SUPPORT_CHECK" = upgrade-prepare-v1 || exit 91 -test "$1" = --help || exit 92 -printf 'vite-plus-self-setup-v1\n' -"#, - ); - assert!(!prepare(&binary, true, None).await.unwrap()); - }) - .await; - } - - #[tokio::test] - async fn failed_probe_does_not_fall_back() { - EnvConfig::scoped_async(|config| async move { - let binary = executable(&config.dirs.data, "#!/bin/sh\nexit 42\n"); - let error = prepare(&binary, true, None).await.unwrap_err(); - assert!(error.to_string().contains("Failed to probe")); - assert!(error.to_string().contains("exit code 42")); - }) - .await; - } - - async fn install_with_helper(exit: &str) { - let registry = httpmock::MockServer::start_async().await; - Box::pin(EnvConfig::scoped_async(|config| async move { - let data = &config.dirs.data; - let old = data.join("old"); - let new = data.join("new"); - tokio::fs::create_dir_all(&old).await.unwrap(); - tokio::fs::create_dir_all(&new).await.unwrap(); - tokio::fs::write(old.join("version"), "old").await.unwrap(); - tokio::fs::write(data.join(".previous-version"), "previous").await.unwrap(); - install::swap_current_link(data, "old").await.unwrap(); - - let result = Box::pin(EnvConfig::with_vars_async( - [ - ("TEST_PREPARE_EXIT", Some(exit)), - (env_vars::NPM_CONFIG_REGISTRY, Some("https://invalid.example")), - (env_vars::NPM_CONFIG_REGISTRY_UPPER, Some("https://invalid.example")), - (env_vars::VP_SETUP_NODE_VERSION, Some("1.0.0")), - (env_vars::VP_SETUP_NODE_VERSION_FILE, Some("must-not-write")), - (env_vars::VP_SELF_SETUP_SHELL, Some("sh")), - (env_vars::VP_DUMP_DIRS, Some("1")), - ("VP_SKIP_DEPS_INSTALL", Some("1")), - ("VP_VERSION", Some("wrong-version")), - ("VP_COMPLETE", Some("bash")), - ], - |_| async { - super::super::install_platform_and_main( - &archive(HELPER), - &new, - data, - "new", - "2.0.0", - "1.0.0", - true, - Some(®istry.base_url()), - ) - .await - }, - )) - .await; - - assert_eq!( - tokio::fs::read_to_string(new.join("prepared")).await.unwrap(), - registry.base_url() - ); - assert!( - !new.join("package.json").as_path().exists(), - "parent must not generate the target's wrapper" - ); - if exit == "0" { - result.unwrap(); - assert_eq!(install::read_current_version(data).await.as_deref(), Some("new")); - assert_eq!( - tokio::fs::read_to_string(data.join(".previous-version")).await.unwrap(), - "old" - ); - assert!(data.join("refreshed").as_path().is_file()); - } else { - let error = result.unwrap_err(); - assert!( - error.to_string().contains("could not prepare its dependencies (exit code 42)") - ); - assert_eq!(install::read_current_version(data).await.as_deref(), Some("old")); - assert_eq!( - tokio::fs::read_to_string(data.join(".previous-version")).await.unwrap(), - "previous" - ); - assert!(!data.join("refreshed").as_path().exists()); - } - })) - .await; - } - - #[tokio::test] - async fn successful_target_preparation_precedes_activation() { - Box::pin(install_with_helper("0")).await; - } - - #[tokio::test] - async fn failed_target_preparation_preserves_activation_and_rollback() { - Box::pin(install_with_helper("42")).await; - } -} diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 59c7c946e1..d72c6533f3 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -23,7 +23,6 @@ mod js_executor; mod self_setup; mod shim; mod upgrade_check; -mod upgrade_prepare; use std::{ env, @@ -315,8 +314,8 @@ fn dump_dirs_from_env_config() -> bool { #[tokio::main] async fn main() -> ExitCode { // Probe before tracing, directory resolution, or argument dispatch can emit output. - if let Some(request) = env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK) { - output::print_stdout(format_args!("{}", vp_setup::upgrade::capability_response(&request))); + if env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK).is_some() { + vp_shared::output::print_stdout_line(format_args!("vite-plus-self-setup-v1")); return ExitCode::SUCCESS; } @@ -331,15 +330,6 @@ async fn main() -> ExitCode { // Initialize tracing vp_shared::init_tracing(); - match upgrade_prepare::maybe_run().await { - Ok(Some(code)) => return code, - Ok(None) => {} - Err(error) => { - output::error(&error.to_string()); - return ExitCode::FAILURE; - } - } - // Internal directory queries are also used by the local installer before deployment. if dump_dirs_from_env_config() { return ExitCode::SUCCESS; diff --git a/crates/vp_global_cli/src/upgrade_prepare.rs b/crates/vp_global_cli/src/upgrade_prepare.rs deleted file mode 100644 index cd91f70002..0000000000 --- a/crates/vp_global_cli/src/upgrade_prepare.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Prepare the downloaded release's dependencies without changing the active installation. - -use std::{ffi::OsString, process::ExitCode}; - -use vp_setup::{VP_BINARY_NAME, install, upgrade}; -use vp_shared::EnvConfig; -use vt_path::{AbsolutePath, AbsolutePathBuf}; - -use crate::error::Error; - -pub(crate) async fn maybe_run() -> Result, Error> { - let Some(protocol) = std::env::var_os(upgrade::PREPARE_ENV) else { - return Ok(None); - }; - if protocol != upgrade::PREPARE_PROTOCOL { - return Err(Error::Other("Unsupported upgrade preparation protocol".into())); - } - let silent = parse_args(std::env::args_os().skip(1))?; - vp_shared::validate_vp_dir_env().map_err(|error| Error::Other(error.to_string().into()))?; - let binary = AbsolutePathBuf::new(std::env::current_exe()?).ok_or(Error::CliBinaryNotFound)?; - let version_dir = staged_version_dir(&binary, &EnvConfig::get().dirs.data)?; - // The target release owns all installation pins and rules. Initial-install - // overrides, project configuration, and skip-deps flags do not apply here. - install::generate_wrapper_package_json(&version_dir, env!("CARGO_PKG_VERSION")).await?; - // The caller exports an explicit registry to both npm environment spellings. - // Otherwise preserve the inherited registry precedence used by npm and pnpm. - install::install_production_deps(&version_dir, None, !silent, None).await?; - Ok(Some(ExitCode::SUCCESS)) -} - -fn parse_args(args: impl Iterator) -> Result { - let mut silent = false; - for arg in args { - if arg == "--silent" && !silent { - silent = true; - } else { - return Err(Error::Other("Upgrade preparation accepts only --silent".into())); - } - } - Ok(silent) -} - -fn staged_version_dir( - binary: &AbsolutePath, - data: &AbsolutePath, -) -> Result { - let binary = dunce::canonicalize(binary)?; - let data = dunce::canonicalize(data)?; - let bin = binary.parent().ok_or(Error::CliBinaryNotFound)?; - let version_dir = bin.parent().ok_or(Error::CliBinaryNotFound)?; - if binary.file_name() != Some(std::ffi::OsStr::new(VP_BINARY_NAME)) - || bin.file_name() != Some(std::ffi::OsStr::new("bin")) - || version_dir.parent() != Some(data.as_path()) - { - return Err(Error::Other( - "Upgrade preparation requires a staged binary in the installation data directory" - .into(), - )); - } - let current = data.join("current").join("bin").join(VP_BINARY_NAME); - if same_file::is_same_file(&binary, ¤t).unwrap_or(false) { - return Err(Error::Other("Cannot prepare an upgrade in the active installation".into())); - } - AbsolutePathBuf::new(version_dir.to_path_buf()).ok_or(Error::CliBinaryNotFound) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn preparation_accepts_only_its_own_options() { - assert!(!parse_args([].into_iter()).unwrap()); - assert!(parse_args(["--silent".into()].into_iter()).unwrap()); - for args in [vec!["--help"], vec!["upgrade"], vec!["--silent", "--silent"]] { - assert!(parse_args(args.into_iter().map(Into::into)).is_err()); - } - } - - #[tokio::test] - async fn preparation_requires_an_inactive_staged_binary() { - let root = tempfile::tempdir().unwrap(); - let data = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); - let staged = data.join("1.4.0+force.test"); - let binary = staged.join("bin").join(VP_BINARY_NAME); - tokio::fs::create_dir_all(binary.parent().unwrap()).await.unwrap(); - tokio::fs::write(&binary, b"target").await.unwrap(); - assert_eq!( - staged_version_dir(&binary, &data).unwrap().as_path(), - dunce::canonicalize(&staged).unwrap(), - ); - - install::swap_current_link(&data, "1.4.0+force.test").await.unwrap(); - assert!(staged_version_dir(&binary, &data).is_err()); - assert!(staged_version_dir(&data.join("current/bin").join(VP_BINARY_NAME), &data).is_err()); - - let external = tempfile::tempdir().unwrap(); - let external = AbsolutePathBuf::new(external.path().to_path_buf()).unwrap(); - assert!(staged_version_dir(&binary, &external).is_err()); - } -} diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index b566dc8a2b..81718f6eeb 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -344,9 +344,7 @@ async fn run_pnpm_install( .args(args) .current_dir(version_dir) .env("CI", "true") - .env("PATH", path) - // Lifecycle commands must not inherit the upgrade helper's entry point. - .env_remove(crate::upgrade::PREPARE_ENV); + .env("PATH", path); if args.contains(&"--frozen-lockfile") { // --config leaves numbers as strings. Environment values get pnpm's numeric parsing. diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index 1c39928014..d81316381f 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -21,7 +21,6 @@ pub mod integrity; pub mod lockfile; pub mod platform; pub mod registry; -pub mod upgrade; /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 3; diff --git a/crates/vp_setup/src/upgrade.rs b/crates/vp_setup/src/upgrade.rs deleted file mode 100644 index 6f6190565c..0000000000 --- a/crates/vp_setup/src/upgrade.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Versioned handoff for preparing an upgrade before activation. - -/// Request value for both the capability probe and the preparation entry point. -pub const PREPARE_PROTOCOL: &str = "upgrade-prepare-v1"; - -/// Exact response to the preparation capability probe. -pub const PREPARE_CAPABILITY: &str = "vite-plus-upgrade-prepare-v1\n"; - -/// Internal entry point that prepares dependencies without activating the release. -pub const PREPARE_ENV: &str = "VP_UPGRADE_PREPARE"; - -/// Preserve the original bootstrap response for existing shell and Windows installers. -pub fn capability_response(request: &std::ffi::OsStr) -> &'static str { - if request == PREPARE_PROTOCOL { PREPARE_CAPABILITY } else { "vite-plus-self-setup-v1\n" } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bootstrap_probe_remains_compatible() { - for request in ["1", "", "unknown"] { - assert_eq!(capability_response(request.as_ref()), "vite-plus-self-setup-v1\n"); - } - assert_eq!(capability_response(PREPARE_PROTOCOL.as_ref()), PREPARE_CAPABILITY); - } -} diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 044c0ae54f..03c9f6bc43 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -246,7 +246,7 @@ pub const VP_INSECURE_TLS: &str = "VP_INSECURE_TLS"; /// do not implement directory resolution again. pub const VP_DUMP_DIRS: &str = "VP_DUMP_DIRS"; -/// Bootstrap capability probe; upgrade preparation uses a versioned request value. +/// Bootstrap capability probe; presence requests only the self-setup contract. pub const VP_SELF_SETUP_SUPPORT_CHECK: &str = "VP_SELF_SETUP_SUPPORT_CHECK"; /// Skip persistent shell/PATH changes during first-start installation. diff --git a/rfcs/merge-global-and-local-cli.md b/rfcs/merge-global-and-local-cli.md index d7dc9aa986..020f52f1f5 100644 --- a/rfcs/merge-global-and-local-cli.md +++ b/rfcs/merge-global-and-local-cli.md @@ -99,13 +99,10 @@ This decouples the `vp` binary from vite-plus's internal file layout. `--frozen-lockfile`. `install.ps1` uses the same flow on Windows. - **Upgrade** (`vp upgrade`): - Downloads and verifies the CLI platform archive, then asks a supported target - binary to prepare dependencies before activation. The target generates the - wrapper `package.json` and installs with its own Node.js pin, pnpm pin, and - bundled lockfile. The running binary switches `current` only after preparation - succeeds. Targets without preparation support and older upgrade clients keep - their existing installation path. See the - [preparation protocol](./upgrade-command.md#preparation-protocol). + Downloads the CLI platform archive with the binary and lockfile, generates the + wrapper `package.json`, and restores the bundled lockfile before the frozen + dependency install. pnpm downloads `vite-plus` and its dependencies using the + versions and integrity values recorded in that lockfile. - **Local dev** (`pnpm bootstrap-cli`): Copies `vp` binary, generates wrapper `package.json`, symlinks diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 3feb637dc9..5ed1501ed6 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -150,21 +150,21 @@ Options: The upgrade command uses `vp_global_cli` and the shared `vp_setup` library. It installs the same binary and bundled lockfile as the script installers. -The running binary controls activation and rollback. The downloaded target -binary prepares its dependencies with its own Node.js pin, pnpm pin, and -installation rules. - -```mermaid -flowchart LR - A["Running vp
Resolve, download, verify, and extract"] --> P{"Target supports
preparation?"} - P -->|Yes| T["Target vp
Prepare dependencies"] - P -->|"No: successful probe"| L["Running vp
Legacy preparation"] - T -->|Success| S["Running vp
Save rollback version
Switch current"] - L -->|Success| S - S --> R["Refresh setup and shims
Clean old versions
Non-fatal"] - P -->|"Probe failed"| F["Stop upgrade
Keep current and rollback record"] - T -->|Failure| F - L -->|Failure| F + +``` +┌──────────────────────────────────────────────────────┐ +│ vp upgrade │ +├──────────────────────────────────────────────────────┤ +│ 1. Resolve version (registry query) │ +│ 2. Check if already installed │ +│ 3. Download and verify platform archive │ +│ 4. Extract binary + vp-install-lock.yaml │ +│ 5. Generate wrapper and restore pnpm-lock.yaml │ +│ 6. Install dependencies with --frozen-lockfile │ +│ 7. Swap current → {version} │ +│ 8. Refresh shims (non-fatal) │ +│ 9. Cleanup old versions (non-fatal, keep 3) │ +└──────────────────────────────────────────────────────┘ ``` ### Implementation Flow @@ -257,58 +257,14 @@ Platform detection reuses existing logic from `vp_js_runtime` or mirrors the bas - `uname -m` → arch (x64, arm64) - Linux: detect gnu vs musl libc -#### Step 4: Extract and Prepare Dependencies +#### Step 4: Extract and Install 1. Create the version directory under the data root resolved by `VpDirs`, with a `bin/` subdirectory. 2. Extract the binary and `vp-install-lock.yaml` into `bin/`, along with `vp-shim.exe` on Windows. -3. Probe the target binary for the versioned preparation capability. -4. Ask a supported target to prepare its dependencies. Use the legacy parent-side installation only when the probe succeeds without that capability. -5. Wait for successful preparation before recording the rollback version or switching `current`. - -A forced reinstall of the active version uses a separate directory named -`+force..`. This also avoids overwriting the running -executable on Windows. - -##### Preparation Protocol - -The caller runs the downloaded binary with `--help` and -`VP_SELF_SETUP_SUPPORT_CHECK=upgrade-prepare-v1`. A supported target returns -exactly `vite-plus-upgrade-prepare-v1\n` and exits before normal self-setup. -The existing `VP_SELF_SETUP_SUPPORT_CHECK=1` request still returns exactly -`vite-plus-self-setup-v1\n`, so shell and Windows installers keep their contract. -Older self-setup binaries return the original token for the new probe value; -older binaries without self-setup process `--help` normally. - -After a successful capability response, the caller runs the target with -`VP_UPGRADE_PREPARE=upgrade-prepare-v1`. This internal entry point accepts only -an optional `--silent`. It derives its version directory from its executable -path and requires an inactive binary under `//bin/`. -It runs before normal self-setup and does not activate the version, change -management preferences, modify shell files, or write the final setup marker. - -The target generates the wrapper `package.json` using its own -`CARGO_PKG_VERSION` and pinned pnpm version. It uses `DEFAULT_NODE_VERSION`, -embedded from the repository's `.node-version` at build time, for installation. -It restores `bin/vp-install-lock.yaml` as `pnpm-lock.yaml`, writes -`minimum-release-age=0` to the installation's `.npmrc`, and runs managed pnpm -with `install --ignore-workspace --frozen-lockfile`. The existing -`VP_SETUP_SKIP_INSTALL_LOCKFILE=1` bypass remains available. - -The caller pins the resolved `VpDirs` roots in the child environment. An explicit -`--registry` sets both `npm_config_registry` and `NPM_CONFIG_REGISTRY`; otherwise -the existing registry precedence applies. Mirror, proxy, timeout, and lockfile -bypass settings remain available. Initial-install controls such as -`VP_SETUP_NODE_VERSION`, `VP_SETUP_NODE_VERSION_FILE`, and `VP_SKIP_DEPS_INSTALL` -do not control upgrade preparation. The pnpm process does not inherit -`VP_UPGRADE_PREPARE`, so nested commands cannot reenter this helper accidentally. -The helper has no interactive input. Normal runs inherit output streams; -silent runs show captured diagnostics only on failure. - -A failed probe or preparation stops the upgrade and removes the staged version. -The active version and rollback record remain unchanged. A supported target's -installation failure never falls back to the running binary's installation -logic. Already-released upgrade clients retain their original flow because they -do not know this protocol. +3. Generate the wrapper `package.json` with an exact `vite-plus` dependency and the installer's pinned pnpm version. +4. Restore `bin/vp-install-lock.yaml` as `pnpm-lock.yaml` in the version directory. +5. Write the installation's `.npmrc` with `minimum-release-age=0` so new versions are immediately available. +6. Run managed pnpm with `install --ignore-workspace --frozen-lockfile` in the version directory. Activation waits for a successful install. Missing advertised lockfiles and integrity failures stop installation. Older packages without a companion keep the legacy dependency installation behavior. @@ -317,10 +273,6 @@ recovers it from the verified platform archive before completing a frozen install. See [Global installation lockfile](./merge-global-and-local-cli.md#global-installation-lockfile) for generation, retry, and compatibility details. -Successful preparation writes the normal installation receipt. After activation, -the target's self-setup checks that receipt and avoids a second dependency -installation before refreshing setup and shims. - #### Step 5: Version Swap **Unix (macOS/Linux)** — Atomic symlink swap: @@ -385,12 +337,10 @@ After the `current` link swap, any **new** invocation of `vp` will use the new b The `--rollback` flag switches the `current` symlink to the previously active version. -The running binary records the previous version after dependency preparation -succeeds and before activation: +To track the previous version, we can: 1. Read the `current` symlink target before updating -2. Write that version to `/.previous-version` -3. Switch `current` to the prepared version +2. After the update, write the previous version to `~/.vite-plus/.previous-version` For `--rollback`: @@ -406,8 +356,7 @@ For `--rollback`: | Network failure during download | Clean up partial temp files, exit with helpful message | | Integrity mismatch (SHA-512) | Delete downloaded file, report expected vs actual hash, abort | | Corrupted tarball | Verify extraction success, clean up version dir if partial | -| Capability probe fails | Remove the staged version; keep current and rollback unchanged | -| Dependency preparation fails | Remove the staged version; keep current and rollback unchanged | +| `vp install` fails | Remove the version dir, keep current version unchanged | | Disk full | Detect and report, clean up partial state | | Permission denied | Report with suggestion to check directory ownership | | Registry returns error | Parse npm error JSON, show human-readable message | @@ -417,19 +366,18 @@ Key principle: **The `current` symlink is only swapped after all pre-swap steps ### File Structure ``` -crates/vp_global_cli/src/ -├── commands/upgrade/ -│ ├── mod.rs # Download, activation, rollback, and cleanup -│ └── preparation.rs # Target capability probe and preparation handoff -├── upgrade_prepare.rs # Target entry point before normal self-setup -└── self_setup.rs # Setup and shim refresh after activation - -crates/vp_setup/src/ -├── upgrade.rs # Versioned preparation protocol -├── install.rs # Extraction, dependency installation, and version links -├── lockfile.rs # Release lockfile and installation receipt -├── registry.rs # Release resolution and archive metadata -└── platform.rs # Platform detection +crates/vp_global_cli/ +├── src/ +│ ├── commands/ +│ │ ├── upgrade/ +│ │ │ ├── mod.rs # Module root, public execute() function +│ │ │ ├── registry.rs # npm registry client (version resolution, tarball URLs) +│ │ │ ├── platform.rs # Platform detection (os, arch, libc) +│ │ │ ├── download.rs # HTTP download + tarball extraction +│ │ │ └── install.rs # Extract, dependency install, symlink swap, cleanup +│ │ ├── mod.rs # Add upgrade module +│ │ └── ... +│ └── cli.rs # Add Upgrade command variant ``` ### Platform Detection From 7ab932fbd13e1c3aac2e3c096313b935290eea1d Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Oct 2026 23:12:33 +0900 Subject: [PATCH 14/20] docs(runtime): simplify the embedded Node pin comment --- crates/vp_js_runtime/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index 82cb44dd22..393f4377d7 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -54,7 +54,6 @@ mod providers; mod runtime; /// Embed the repository's Node.js pin so installed binaries need no source checkout. -/// `.github/scripts/upgrade-deps.ts` keeps `.node-version` within Node 24. pub const DEFAULT_NODE_VERSION: &str = include_str!("../../../.node-version").trim_ascii(); pub use dev_engines::{ From c4533d1d64f6db967d8c66e06339a44210c6ec6c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Oct 2026 10:12:15 +0900 Subject: [PATCH 15/20] fix(upgrade): use the target Node pin from package metadata --- .../fixtures/download_progress/snapshots.toml | 20 ++ .../snapshots/upgrade_node_metadata.md | 9 + .../upgrade_node_metadata_failure.md | 10 + .../upgrade_node_metadata_incompatible.md | 9 + .../snapshots/upgrade_node_metadata_legacy.md | 9 + .../download_progress/upgrade-node.mjs | 200 ++++++++++++++++++ .../vp_global_cli/src/commands/upgrade/mod.rs | 8 +- crates/vp_global_cli/src/self_setup.rs | 8 +- crates/vp_setup/src/install.rs | 52 ++++- crates/vp_setup/src/registry.rs | 91 +++++++- packages/cli/publish-native-addons.ts | 5 + .../preview-install-lockfile.spec.ts | 6 +- .../__tests__/publish-native-addons.spec.ts | 113 ++++++++++ rfcs/merge-global-and-local-cli.md | 6 +- rfcs/upgrade-command.md | 16 +- 15 files changed, 546 insertions(+), 16 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs create mode 100644 packages/cli/src/__tests__/publish-native-addons.spec.ts 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 4af0391c5a..3dd12fecf0 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 @@ -67,6 +67,26 @@ vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "install.mjs", "upgrade-handoff"], tty = false }] +[[case]] +name = "upgrade_node_metadata" +vp = "global" +steps = [{ argv = ["node", "upgrade-node.mjs", "metadata"], tty = false }] + +[[case]] +name = "upgrade_node_metadata_legacy" +vp = "global" +steps = [{ argv = ["node", "upgrade-node.mjs", "legacy"], tty = false }] + +[[case]] +name = "upgrade_node_metadata_failure" +vp = "global" +steps = [{ argv = ["node", "upgrade-node.mjs", "failure"], tty = false }] + +[[case]] +name = "upgrade_node_metadata_incompatible" +vp = "global" +steps = [{ argv = ["node", "upgrade-node.mjs", "incompatible"], tty = false }] + [[case]] name = "installation_peer_config" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md new file mode 100644 index 0000000000..0f9d7f2078 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md @@ -0,0 +1,9 @@ +# upgrade_node_metadata + +## `node upgrade-node.mjs metadata` + +``` +Upgrade installs dependencies with the target metadata's Node pin before activation. +Activation follows successful installation; setup reuses the completion receipt. +Only the two metadata requests and platform download occur; all stay local. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md new file mode 100644 index 0000000000..da16dab222 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md @@ -0,0 +1,10 @@ +# upgrade_node_metadata_failure + +## `node upgrade-node.mjs failure` + +``` +Upgrade installs dependencies with the target metadata's Node pin before activation. +A failed dependency install removes the staged version. +The current version and rollback record remain unchanged. +Only the two metadata requests and platform download occur; all stay local. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md new file mode 100644 index 0000000000..c111b01e46 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md @@ -0,0 +1,9 @@ +# upgrade_node_metadata_incompatible + +## `node upgrade-node.mjs incompatible` + +``` +An incompatible target Node pin fails without falling back or starting pnpm. +The current version and rollback record remain unchanged. +Only the two metadata requests and platform download occur; all stay local. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md new file mode 100644 index 0000000000..e3593be980 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md @@ -0,0 +1,9 @@ +# upgrade_node_metadata_legacy + +## `node upgrade-node.mjs legacy` + +``` +Missing platform metadata preserves the running binary's embedded Node default. +Activation follows successful installation; setup reuses the completion receipt. +Only the two metadata requests and platform download occur; all stay local. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs new file mode 100644 index 0000000000..9a6595cd10 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { dirname, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { createArchive } from './archive.mjs'; + +const exec = promisify(execFile); +const mode = process.argv[2]; +const legacy = mode === 'legacy'; +const incompatible = mode === 'incompatible'; +const failure = mode === 'failure'; +const windows = process.platform === 'win32'; +const binaryName = windows ? 'vp.exe' : 'vp'; +const runnerHome = process.env.VP_HOME; +const cli = realpathSync(join(runnerHome, 'current/node_modules/vite-plus')); +const defaultNode = readFileSync(resolve(cli, '../../.node-version'), 'utf8').trim(); +const targetNode = '22.23.4'; +assert.notEqual(targetNode, defaultNode); +const expectedNode = legacy ? defaultNode : targetNode; +const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version; +const pnpmVersion = /export const INSTALL_PNPM_VERSION = '([^']+)';/.exec( + readFileSync(join(cli, 'generate-install-lockfile.ts'), 'utf8'), +)[1]; +const home = resolve('upgrade-home'); +const user = resolve('upgrade-user'); +const current = join(home, 'current'); +const old = join(home, version); +const source = join(old, 'bin', binaryName); +const previous = join(home, '.previous-version'); +const attempts = join(home, 'install-attempts'); +const lockfile = "lockfileVersion: '9.0'\n"; + +function write(file, contents) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, contents); +} + +write(previous, 'rollback-sentinel'); +write(join(old, 'bin/.vp-setup-complete'), ''); +write(join(old, 'package.json'), JSON.stringify({ name: 'vp-global', version })); +copyFileSync(join(runnerHome, 'current/bin', binaryName), source); +symlinkSync(old, current, windows ? 'junction' : 'dir'); +mkdirSync(user); + +const payload = { + 'package/package.json': JSON.stringify({ version, vpInstallLockfile: true }), + [`package/${binaryName}`]: readFileSync(source), + 'package/vp-install-lock.yaml': lockfile, +}; +if (windows) { + payload['package/vp-shim.exe'] = readFileSync(join(runnerHome, 'current/bin/vp-shim.exe')); +} +const archive = createArchive(payload); +const requests = []; +let registry; +const server = createServer((request, response) => { + requests.push(request.url); + if (request.url === '/platform.tgz') { + response.end(archive); + return; + } + const main = request.url === '/vite-plus/latest'; + const platform = + request.url.startsWith('/@voidzero-dev/vite-plus-cli-') && + request.url.endsWith(`/${version}`); + if (main || platform) { + // Only the selected platform package determines the installation runtime. + const nodeMetadata = {}; + if (main) nodeMetadata.vpNodeVersion = defaultNode; + else if (!legacy) nodeMetadata.vpNodeVersion = targetNode; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ + version, + vpInstallLockfile: true, + ...nodeMetadata, + dist: { + tarball: `${registry}/platform.tgz`, + integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}`, + attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + }, + })); + return; + } + response.writeHead(404).end('Unexpected network request'); +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +registry = `http://127.0.0.1:${server.address().port}`; + +const nodePath = windows ? 'node.exe' : 'bin/node'; +for (const nodeVersion of [defaultNode, targetNode]) { + const destination = join(home, 'js_runtime/node', nodeVersion, nodePath); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(join(runnerHome, 'js_runtime/node', nodeVersion, nodePath), destination); +} +const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm'); +write(join(pnpm, 'package.json'), JSON.stringify({ + version: pnpmVersion, + engines: { node: incompatible ? `>=${defaultNode}` : '*' }, +})); +for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) write(join(pnpm, 'bin', shim), 'unused'); +write(join(pnpm, 'bin/pnpm.cjs'), ` +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +assert.equal(process.versions.node, ${JSON.stringify(expectedNode)}); +assert.equal(fs.realpathSync(process.execPath), fs.realpathSync(${JSON.stringify(join(home, 'js_runtime/node', expectedNode, nodePath))})); +assert.equal(fs.realpathSync(${JSON.stringify(current)}), fs.realpathSync(${JSON.stringify(old)})); +assert.equal(fs.readFileSync(${JSON.stringify(previous)}, 'utf8'), 'rollback-sentinel'); +assert.ok(process.argv.includes('--frozen-lockfile')); +assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); +assert.equal(JSON.parse(fs.readFileSync('package.json')).dependencies['vite-plus'], ${JSON.stringify(version)}); +fs.appendFileSync(${JSON.stringify(attempts)}, process.cwd() + '\\n'); +if (${failure}) process.exit(17); +fs.mkdirSync('node_modules/vite-plus', { recursive: true }); +fs.writeFileSync('node_modules/vite-plus/package.json', JSON.stringify({name: 'vite-plus', version: ${JSON.stringify(version)}})); +`); + +const env = { + ...process.env, + HOME: user, + USERPROFILE: user, + VP_HOME: home, + VP_NODE_DIST_MIRROR: registry, + npm_config_registry: registry, + NPM_CONFIG_REGISTRY: registry, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NO_UPDATE_CHECK: '1', + CI: '1', + NO_COLOR: '1', +}; +for (const name of [ + 'VP_SELF_SETUP_SHELL', 'VP_DATA_DIR', 'VP_CACHE_DIR', 'VP_BIN_DIR', + 'VP_SKIP_DEPS_INSTALL', 'VP_NODE_VERSION', 'VP_SETUP_NODE_VERSION', 'VP_SETUP_NODE_VERSION_FILE', +]) delete env[name]; + +try { + const result = await exec(source, ['upgrade', '--force', '--silent', '--registry', registry], { + env, + timeout: 30000, + }).then( + (result) => ({ ...result, code: 0 }), + (error) => { + assert.equal(error.signal, null, error.message); + return error; + }, + ); + assert.equal(result.code, failure || incompatible ? 1 : 0, result.stderr); + if (incompatible) { + assert.ok(result.stderr.includes(targetNode), result.stderr); + assert.equal(existsSync(attempts), false); + console.log('An incompatible target Node pin fails without falling back or starting pnpm.'); + } else { + const directories = readFileSync(attempts, 'utf8').trim().split('\n'); + assert.equal(directories.length, 1); + const installed = directories[0]; + console.log(legacy + ? 'Missing platform metadata preserves the running binary\'s embedded Node default.' + : 'Upgrade installs dependencies with the target metadata\'s Node pin before activation.'); + if (failure) { + assert.equal(existsSync(installed), false); + console.log('A failed dependency install removes the staged version.'); + } else { + assert.equal(realpathSync(current), realpathSync(installed)); + assert.equal(readFileSync(previous, 'utf8'), version); + assert.deepEqual(readFileSync(join(installed, 'node_modules/.vp-install-complete')), + createHash('sha256').update(lockfile).digest()); + console.log('Activation follows successful installation; setup reuses the completion receipt.'); + } + } + if (failure || incompatible) { + assert.equal(realpathSync(current), realpathSync(old)); + assert.equal(readFileSync(previous, 'utf8'), 'rollback-sentinel'); + console.log('The current version and rollback record remain unchanged.'); + } + assert.equal(requests.length, 3); + assert.equal(requests[0], '/vite-plus/latest'); + assert.ok(requests[1].endsWith(`/${version}`)); + assert.equal(requests[2], '/platform.tgz'); + console.log('Only the two metadata requests and platform download occur; all stay local.'); +} finally { + server.close(); + server.closeAllConnections(); + for (const directory of [home, user]) { + rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +} diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index f2b2540090..bda662be3e 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -187,7 +187,7 @@ pub async fn execute(options: UpgradeOptions) -> Result { &version_dir, install_dir, &install_dir_name, - &resolved.version, + &resolved, current_version, options.silent, options.registry.as_deref(), @@ -210,11 +210,12 @@ async fn install_platform_and_main( version_dir: &AbsolutePathBuf, install_dir: &AbsolutePathBuf, install_dir_name: &str, - new_version: &str, + resolved: ®istry::ResolvedVersion, current_version: &str, silent: bool, registry: Option<&str>, ) -> Result { + let new_version = &resolved.version; // Extract platform package (binary only; .node files installed via npm optionalDeps) install::extract_platform_package(platform_data, version_dir).await?; @@ -231,7 +232,8 @@ 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, None).await?; + let node_version = resolved.node_version.as_deref().map(install::NodeVersion::Required); + install::install_production_deps(version_dir, registry, !silent, node_version).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 cc5bb635d3..33d74c8129 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -263,7 +263,13 @@ async fn run(source: &Path, bundled: bool) -> Result { "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?; + install::install_production_deps( + &version_dir, + registry, + true, + preferred.map(install::NodeVersion::Preferred), + ) + .await?; } } if let Some(version) = node_version { diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 81718f6eeb..73c1131e10 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -197,6 +197,14 @@ pub async fn write_upgrade_log( } } +/// Select the runtime for dependency installation. +pub enum NodeVersion<'a> { + /// Reuse a CI-selected runtime when it supports the pinned pnpm. + Preferred(&'a str), + /// Use the target release's exact runtime or fail before activation. + Required(&'a str), +} + /// Install production dependencies with a compatible managed Node.js and pinned pnpm. /// /// Spawns: `node /bin/pnpm.cjs install --ignore-workspace [--registry ]` @@ -205,7 +213,7 @@ pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, show_progress: bool, - preferred_node_version: Option<&str>, + node_version: Option>, ) -> Result<(), Error> { tracing::debug!("Running pnpm install in {}", version_dir.as_path().display()); lockfile::invalidate_install(version_dir).await?; @@ -222,7 +230,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(preferred_node_version), + prepare_install_runtime(node_version), ) .await?; let output = vp_shared::progress::with_spinner( @@ -280,7 +288,7 @@ async fn dependency_install_args<'a>( } async fn prepare_install_runtime( - preferred_node_version: Option<&str>, + node_version: Option>, ) -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { let (pnpm_dir, _, _) = download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None) @@ -302,9 +310,21 @@ async fn prepare_install_runtime( .map_err(|error| { Error::Setup(format!("Failed to read pnpm's Node.js requirement: {error}").into()) })?; - let node_version = preferred_node_version - .filter(|version| supports_pnpm_node(&package, version)) - .unwrap_or(DEFAULT_NODE_VERSION); + let node_version = match node_version { + Some(NodeVersion::Required(version)) => { + if !supports_pnpm_node(&package, version) { + return Err(Error::Setup( + format!( + "Node.js {version} required by the target Vite+ release is not supported by pnpm {PINNED_PNPM_VERSION}" + ) + .into(), + )); + } + version + } + Some(NodeVersion::Preferred(version)) if supports_pnpm_node(&package, version) => version, + _ => DEFAULT_NODE_VERSION, + }; 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()) @@ -712,19 +732,35 @@ mod tests { tokio::fs::write(binary, "").await.unwrap(); } - let (runtime, entry) = prepare_install_runtime(Some("22.23.1")).await.unwrap(); + let (runtime, entry) = + prepare_install_runtime(Some(NodeVersion::Preferred("22.23.1"))).await.unwrap(); assert_eq!(runtime.version(), "22.23.1"); assert_eq!(entry, pnpm.join("bin/pnpm.cjs")); index.assert_calls(0); + let (runtime, _) = + prepare_install_runtime(Some(NodeVersion::Required("22.23.1"))).await.unwrap(); + assert_eq!(runtime.version(), "22.23.1"); + index.assert_calls(0); + let (runtime, _) = prepare_install_runtime(None).await.unwrap(); assert_eq!(runtime.version(), DEFAULT_NODE_VERSION); 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(); + let (runtime, _) = + prepare_install_runtime(Some(NodeVersion::Preferred("22.23.1"))).await.unwrap(); assert_eq!(runtime.version(), DEFAULT_NODE_VERSION); index.assert_calls(0); + + let error = prepare_install_runtime(Some(NodeVersion::Required("22.23.1"))) + .await + .err() + .unwrap(); + assert!(error.to_string().contains( + "Node.js 22.23.1 required by the target Vite+ release is not supported by pnpm" + )); + index.assert_calls(0); }, ) .await; diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index 36198ed549..439e971d37 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -3,7 +3,7 @@ //! Queries the npm registry to resolve versions and get tarball URLs //! with integrity hashes for both the main package and platform-specific package. -use serde::Deserialize; +use serde::{Deserialize, Deserializer, de}; use vp_pm_cli::{HttpClient, npm_registry}; use crate::{error::Error, is_commit_preview_version}; @@ -15,6 +15,21 @@ pub struct PackageVersionMetadata { pub dist: DistInfo, #[serde(default, rename = "vpInstallLockfile")] pub has_install_lockfile: bool, + #[serde(default, rename = "vpNodeVersion", deserialize_with = "deserialize_node_version")] + pub node_version: Option, +} + +fn deserialize_node_version<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let version = String::deserialize(deserializer)?; + let is_exact = js_semver::Version::parse(&version).is_ok_and(|parsed| { + parsed.pre_release.is_empty() && parsed.build.is_empty() && parsed.to_string() == version + }); + if !is_exact { + return Err(de::Error::custom("vpNodeVersion must be an exact Node.js version (x.y.z)")); + } + Ok(Some(version)) } /// Distribution info from npm registry. @@ -45,6 +60,7 @@ pub struct ResolvedVersion { pub platform_tarball_url: String, pub platform_integrity: String, pub has_install_lockfile: bool, + pub node_version: Option, } const MAIN_PACKAGE_NAME: &str = "vite-plus"; @@ -141,6 +157,7 @@ pub async fn resolve_platform_package( platform_tarball_url: cli_meta.dist.tarball, platform_integrity: cli_meta.dist.integrity, has_install_lockfile: cli_meta.has_install_lockfile, + node_version: cli_meta.node_version, }) } @@ -286,6 +303,75 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_platform_node_version_rejects_non_exact_versions() { + for node_version in serde_json::json!([ + null, + 24, + false, + {}, + "", + "lts", + "latest", + "24", + "24.22", + "^24.22.1", + "v24.22.1", + " 24.22.1", + "24.22.1\n", + "24.22.1-rc.1", + "24.22.1+build", + "024.22.1" + ]) + .as_array() + .unwrap() + { + let metadata = serde_json::json!({ + "version": TEST_VERSION, + "dist": dist_with_provenance("https://slsa.dev/provenance/v1"), + "vpNodeVersion": node_version, + }); + assert!( + serde_json::from_value::(metadata).is_err(), + "accepted invalid vpNodeVersion: {node_version}", + ); + } + } + + #[tokio::test] + async fn test_resolve_node_version_from_exact_platform_metadata() { + for node_version in [Some("22.23.1"), None] { + let server = MockServer::start(); + let main_mock = server.mock(|when, then| { + when.method(GET).path("/vite-plus/latest"); + then.status(200).json_body(serde_json::json!({ + "version": TEST_VERSION, + "dist": dist_with_provenance("https://slsa.dev/provenance/v1"), + "vpNodeVersion": "24.22.1", + })); + }); + let mut platform_metadata = serde_json::json!({ + "version": TEST_VERSION, + "dist": dist_with_provenance("https://slsa.dev/provenance/v1"), + }); + if let Some(node_version) = node_version { + platform_metadata["vpNodeVersion"] = node_version.into(); + } + let platform_mock = server.mock(|when, then| { + when.method(GET).path(format!("/{TEST_PACKAGE_NAME}/{TEST_VERSION}")); + then.status(200).json_body(platform_metadata); + }); + + let resolved = + resolve_version("latest", "darwin-arm64", Some(&server.base_url())).await.unwrap(); + + main_mock.assert_calls(1); + platform_mock.assert_calls(1); + assert_eq!(resolved.version, TEST_VERSION); + assert_eq!(resolved.node_version.as_deref(), node_version); + } + } + #[tokio::test] async fn test_resolve_platform_package_returns_verified_distribution() { let server = MockServer::start(); @@ -306,6 +392,7 @@ mod tests { assert_eq!(resolved.version, TEST_VERSION); assert_eq!(resolved.platform_tarball_url, "https://registry.example.test/platform.tgz"); assert_eq!(resolved.platform_integrity, "sha512-test"); + assert!(resolved.node_version.is_none()); } #[tokio::test] @@ -314,6 +401,7 @@ mod tests { let server = MockServer::start(); let metadata = serde_json::json!({ "version": version, + "vpNodeVersion": "24.22.1", "dist": { "tarball": format!("{}/platform.tgz", server.base_url()), "integrity": "sha512-preview", @@ -336,6 +424,7 @@ mod tests { assert_eq!(resolved.version, version); assert_eq!(resolved.platform_tarball_url, format!("{}/platform.tgz", server.base_url())); assert_eq!(resolved.platform_integrity, "sha512-preview"); + assert_eq!(resolved.node_version.as_deref(), Some("24.22.1")); } #[tokio::test] diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index 6421e77f54..31ab4386f1 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -26,6 +26,10 @@ const cli = new NapiCli(); const currentDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(currentDir, '..', '..'); +const nodeVersion = readFileSync(join(repoRoot, '.node-version'), 'utf8').trim(); +if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(nodeVersion)) { + throw new Error('.node-version must contain an exact stable Node.js version'); +} const args = process.argv.slice(2); const modeIdx = args.indexOf('--mode'); @@ -225,6 +229,7 @@ async function prepareAndPublishCliPackage(napiTarget: string): Promise { cpu: [arch], ...(abi ? { libc: [abi] } : {}), files, + vpNodeVersion: nodeVersion, ...(!skipNpmPublish ? { vpInstallLockfile: true } : {}), description: `Vite+ CLI binary for ${platformArchABI}`, repository: cliPackageJson.repository, diff --git a/packages/cli/src/__tests__/preview-install-lockfile.spec.ts b/packages/cli/src/__tests__/preview-install-lockfile.spec.ts index 12447ee327..c43ce054ac 100644 --- a/packages/cli/src/__tests__/preview-install-lockfile.spec.ts +++ b/packages/cli/src/__tests__/preview-install-lockfile.spec.ts @@ -54,7 +54,10 @@ async function withBatch(run: (directory: string) => Promise) { await writeFile( join(directory, `pkg-${index}.tgz`), await createTarGzip([ - { name: 'package/package.json', data: JSON.stringify({ name, version, files: ['vp'] }) }, + { + name: 'package/package.json', + data: JSON.stringify({ name, version, files: ['vp'], vpNodeVersion: '24.22.1' }), + }, { name: 'package/vp', data: 'binary', attrs: { mode: '755' } }, ]), ); @@ -82,6 +85,7 @@ describe('preview installation lockfile', () => { decoder.decode(entries.find((entry) => entry.name === 'package/package.json')!.data), ); expect(manifest.vpInstallLockfile).toBe(true); + expect(manifest.vpNodeVersion).toBe('24.22.1'); expect(manifest.files).toEqual(['vp', INSTALL_LOCKFILE]); expect( decoder.decode( diff --git a/packages/cli/src/__tests__/publish-native-addons.spec.ts b/packages/cli/src/__tests__/publish-native-addons.spec.ts new file mode 100644 index 0000000000..b64c58dbf6 --- /dev/null +++ b/packages/cli/src/__tests__/publish-native-addons.spec.ts @@ -0,0 +1,113 @@ +import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, expect, test, vi } from 'vite-plus/test'; + +import { publishNpmPackageFromEnv } from '../../../../.github/scripts/publish-npm-package.ts'; + +vi.mock('node:fs', () => ({ + copyFileSync: vi.fn(), + existsSync: (path: string) => !path.endsWith('rust-cli-artifacts'), + chmodSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + rmSync: vi.fn(), + writeFileSync: vi.fn(), +})); +vi.mock('node:fs/promises', () => ({ readdir: async () => [] })); +vi.mock('@napi-rs/cli', () => ({ + NapiCli: class { + async createNpmDirs() {} + async artifacts() {} + async prePublish() {} + }, + parseTriple: () => ({ platform: 'darwin', arch: 'arm64', platformArchABI: 'darwin-arm64' }), +})); +vi.mock('../../package.json', () => ({ + default: { + napi: { targets: ['aarch64-apple-darwin'], packageName: '@voidzero-dev/vite-plus' }, + }, +})); +vi.mock('../utils/json.ts', () => ({ + editJsonFile: vi.fn(), + readJsonFile: () => ({ + version: '1.4.0', + optionalDependencies: { '@voidzero-dev/vite-plus-darwin-arm64': '1.4.0' }, + }), +})); +vi.mock('../../generate-install-lockfile.ts', () => ({ + INSTALL_LOCKFILE: 'vp-install-lock.yaml', + validateInstallLockfile: vi.fn(), +})); +vi.mock('../../../../.github/scripts/publish-npm-package.ts', () => ({ + publishNpmPackageFromEnv: vi.fn(async () => 'published'), +})); +vi.mock('../../../../.github/scripts/wait-for-npm-packages.ts', () => ({ + waitForNpmPackagesFromEnv: vi.fn(), +})); + +const originalArgv = process.argv; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('VERSION', '1.4.0'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(readFileSync).mockImplementation((path) => + String(path).endsWith('.node-version') ? '24.22.1\n' : 'lockfile', + ); +}); + +afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +test.each([ + { mode: 'npm', nodeVersion: '22.23.4' }, + { mode: 'pkg-pr-new', nodeVersion: '24.22.1' }, +])('embeds the repository Node pin in $mode platform packages', async ({ mode, nodeVersion }) => { + vi.mocked(readFileSync).mockImplementation((path) => + String(path).endsWith('.node-version') ? `${nodeVersion}\n` : 'lockfile', + ); + process.argv = ['node', 'publish-native-addons.ts', '--mode', mode]; + if (mode === 'npm') { + process.argv.push('--phase', 'cli'); + } + await import('../../publish-native-addons.ts'); + + expect(readFileSync).toHaveBeenCalledWith(expect.stringMatching(/[/\\]\.node-version$/), 'utf8'); + expect(writeFileSync).toHaveBeenCalledOnce(); + const [path, contents] = vi.mocked(writeFileSync).mock.calls[0]; + expect(path).toEqual(expect.stringContaining(join('cli-npm', 'darwin-arm64', 'package.json'))); + expect(JSON.parse(contents as string)).toMatchObject({ + name: '@voidzero-dev/vite-plus-cli-darwin-arm64', + version: '1.4.0', + vpNodeVersion: nodeVersion, + }); + if (mode === 'npm') { + expect(publishNpmPackageFromEnv).toHaveBeenCalledWith( + expect.objectContaining({ vpNodeVersion: nodeVersion }), + 'npm', + expect.any(Array), + expect.any(String), + ); + } else { + expect(publishNpmPackageFromEnv).not.toHaveBeenCalled(); + } +}); + +test.each(['', 'lts', '24', '24.22', 'v24.22.1', '024.22.1', '24.22.1-rc.1', '24.22.1+build'])( + 'rejects an invalid Node pin before packaging: %j', + async (pin) => { + process.argv = ['node', 'publish-native-addons.ts', '--mode', 'npm', '--phase', 'cli']; + vi.mocked(readFileSync).mockReturnValue(pin); + await expect(import('../../publish-native-addons.ts')).rejects.toThrow( + '.node-version must contain an exact stable Node.js version', + ); + expect(copyFileSync).not.toHaveBeenCalled(); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(publishNpmPackageFromEnv).not.toHaveBeenCalled(); + }, +); diff --git a/rfcs/merge-global-and-local-cli.md b/rfcs/merge-global-and-local-cli.md index 020f52f1f5..6579a1a2b9 100644 --- a/rfcs/merge-global-and-local-cli.md +++ b/rfcs/merge-global-and-local-cli.md @@ -102,7 +102,11 @@ This decouples the `vp` binary from vite-plus's internal file layout. Downloads the CLI platform archive with the binary and lockfile, generates the wrapper `package.json`, and restores the bundled lockfile before the frozen dependency install. pnpm downloads `vite-plus` and its dependencies using the - versions and integrity values recorded in that lockfile. + versions and integrity values recorded in that lockfile. The target's platform + metadata supplies its Node.js pin through `vpNodeVersion`; older packages + without this field keep the running binary's selection. See the + [upgrade flow](./upgrade-command.md#step-4-extract-and-install) for validation + and activation behavior. - **Local dev** (`pnpm bootstrap-cli`): Copies `vp` binary, generates wrapper `package.json`, symlinks diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 5ed1501ed6..92ad98657b 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -200,6 +200,11 @@ The archive contains `vp` or `vp.exe`, the bundled `vpInstallLockfile: true` to advertise the companion. Release and commit preview packages use this layout; preview metadata supplies a content-addressed URL. +The platform manifest also publishes `vpNodeVersion`, the exact Node.js version +from the release checkout's `.node-version`. The binary embeds the same pin at +build time. The existing exact-version metadata request supplies this field; +upgrades do not read the current repository pin or resolve another `latest` tag. + The upgrade command does not extract the main JavaScript package itself. The subsequent pnpm installation downloads `vite-plus`, NAPI packages, and transitive dependencies using the bundled lockfile. @@ -264,7 +269,7 @@ Platform detection reuses existing logic from `vp_js_runtime` or mirrors the bas 3. Generate the wrapper `package.json` with an exact `vite-plus` dependency and the installer's pinned pnpm version. 4. Restore `bin/vp-install-lock.yaml` as `pnpm-lock.yaml` in the version directory. 5. Write the installation's `.npmrc` with `minimum-release-age=0` so new versions are immediately available. -6. Run managed pnpm with `install --ignore-workspace --frozen-lockfile` in the version directory. Activation waits for a successful install. +6. Run managed pnpm with `install --ignore-workspace --frozen-lockfile` in the version directory, using `vpNodeVersion` from the target's platform metadata. Activation waits for a successful install. Missing advertised lockfiles and integrity failures stop installation. Older packages without a companion keep the legacy dependency installation behavior. @@ -273,6 +278,15 @@ recovers it from the verified platform archive before completing a frozen install. See [Global installation lockfile](./merge-global-and-local-cli.md#global-installation-lockfile) for generation, retry, and compatibility details. +If `vpNodeVersion` is absent, the running binary keeps its existing Node.js +selection. A present value must be an exact stable version. Invalid metadata, +an incompatible pnpm runtime, or a failed dependency install stops the upgrade +before it changes `current` or the rollback record. Unlike the optional +`VP_SETUP_NODE_VERSION` override for initial installation, this release pin +cannot silently fall back to the running binary's default. The running binary +still selects pnpm and executes the installation logic. Clients released before +this metadata support keep their existing behavior. + #### Step 5: Version Swap **Unix (macOS/Linux)** — Atomic symlink swap: From 98505565f29db7c27cfb7cfe1f7d70fb8b7dec79 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Oct 2026 10:29:59 +0900 Subject: [PATCH 16/20] test(upgrade): snapshot Node metadata observations --- .../snapshots/upgrade_node_metadata.md | 17 +++- .../upgrade_node_metadata_failure.md | 18 ++++- .../upgrade_node_metadata_incompatible.md | 13 ++- .../snapshots/upgrade_node_metadata_legacy.md | 17 +++- .../download_progress/upgrade-node.mjs | 80 +++++++++---------- 5 files changed, 92 insertions(+), 53 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md index 0f9d7f2078..ae35b947c7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md @@ -3,7 +3,18 @@ ## `node upgrade-node.mjs metadata` ``` -Upgrade installs dependencies with the target metadata's Node pin before activation. -Activation follows successful installation; setup reuses the completion receipt. -Only the two metadata requests and platform download occur; all stay local. +Upgrade exit: 0 +stderr: (empty) +Install attempts: 1 +Attempt 1: runtime=22.23.4; expected executable=true + During install: current is original=true; rollback=rollback-sentinel + Install inputs: frozen=true; lockfile matches=true; dependency matches=true + After upgrade: staged exists=true; receipt matches=true; current is staged=true +Current is original: false +Rollback: +Remaining staged directories: 1 +Requests: + /vite-plus/latest + /@voidzero-dev/vite-plus-cli-/ + /platform.tgz ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md index da16dab222..fac8ba9341 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md @@ -3,8 +3,18 @@ ## `node upgrade-node.mjs failure` ``` -Upgrade installs dependencies with the target metadata's Node pin before activation. -A failed dependency install removes the staged version. -The current version and rollback record remain unchanged. -Only the two metadata requests and platform download occur; all stay local. +Upgrade exit: 1 +stderr: error: Setup error: Failed to install production dependencies (exit code: 17). See log for details: /upgrade.log +Install attempts: 1 +Attempt 1: runtime=22.23.4; expected executable=true + During install: current is original=true; rollback=rollback-sentinel + Install inputs: frozen=true; lockfile matches=true; dependency matches=true + After upgrade: staged exists=false; receipt matches=false; current is staged=false +Current is original: true +Rollback: rollback-sentinel +Remaining staged directories: 0 +Requests: + /vite-plus/latest + /@voidzero-dev/vite-plus-cli-/ + /platform.tgz ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md index c111b01e46..ad5d79c4a5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md @@ -3,7 +3,14 @@ ## `node upgrade-node.mjs incompatible` ``` -An incompatible target Node pin fails without falling back or starting pnpm. -The current version and rollback record remain unchanged. -Only the two metadata requests and platform download occur; all stay local. +Upgrade exit: 1 +stderr: error: Setup error: Node.js required by the target Vite+ release is not supported by pnpm +Install attempts: 0 +Current is original: true +Rollback: rollback-sentinel +Remaining staged directories: 0 +Requests: + /vite-plus/latest + /@voidzero-dev/vite-plus-cli-/ + /platform.tgz ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md index e3593be980..34dcf77dbe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md @@ -3,7 +3,18 @@ ## `node upgrade-node.mjs legacy` ``` -Missing platform metadata preserves the running binary's embedded Node default. -Activation follows successful installation; setup reuses the completion receipt. -Only the two metadata requests and platform download occur; all stay local. +Upgrade exit: 0 +stderr: (empty) +Install attempts: 1 +Attempt 1: runtime=; expected executable=true + During install: current is original=true; rollback=rollback-sentinel + Install inputs: frozen=true; lockfile matches=true; dependency matches=true + After upgrade: staged exists=true; receipt matches=true; current is staged=true +Current is original: false +Rollback: +Remaining staged directories: 1 +Requests: + /vite-plus/latest + /@voidzero-dev/vite-plus-cli-/ + /platform.tgz ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs index 9a6595cd10..73ed932c3f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, + readdirSync, realpathSync, rmSync, symlinkSync, @@ -114,16 +115,17 @@ write(join(pnpm, 'package.json'), JSON.stringify({ })); for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) write(join(pnpm, 'bin', shim), 'unused'); write(join(pnpm, 'bin/pnpm.cjs'), ` -const assert = require('node:assert/strict'); const fs = require('node:fs'); -assert.equal(process.versions.node, ${JSON.stringify(expectedNode)}); -assert.equal(fs.realpathSync(process.execPath), fs.realpathSync(${JSON.stringify(join(home, 'js_runtime/node', expectedNode, nodePath))})); -assert.equal(fs.realpathSync(${JSON.stringify(current)}), fs.realpathSync(${JSON.stringify(old)})); -assert.equal(fs.readFileSync(${JSON.stringify(previous)}, 'utf8'), 'rollback-sentinel'); -assert.ok(process.argv.includes('--frozen-lockfile')); -assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); -assert.equal(JSON.parse(fs.readFileSync('package.json')).dependencies['vite-plus'], ${JSON.stringify(version)}); -fs.appendFileSync(${JSON.stringify(attempts)}, process.cwd() + '\\n'); +fs.appendFileSync(${JSON.stringify(attempts)}, JSON.stringify({ + directory: fs.realpathSync(process.cwd()), + runtime: process.versions.node, + executableMatches: fs.realpathSync(process.execPath) === fs.realpathSync(${JSON.stringify(join(home, 'js_runtime/node', expectedNode, nodePath))}), + currentIsOriginal: fs.realpathSync(${JSON.stringify(current)}) === fs.realpathSync(${JSON.stringify(old)}), + rollback: fs.readFileSync(${JSON.stringify(previous)}, 'utf8'), + frozen: process.argv.includes('--frozen-lockfile'), + lockfileMatches: fs.readFileSync('pnpm-lock.yaml', 'utf8') === ${JSON.stringify(lockfile)}, + dependencyMatches: JSON.parse(fs.readFileSync('package.json')).dependencies['vite-plus'] === ${JSON.stringify(version)}, +}) + '\\n'); if (${failure}) process.exit(17); fs.mkdirSync('node_modules/vite-plus', { recursive: true }); fs.writeFileSync('node_modules/vite-plus/package.json', JSON.stringify({name: 'vite-plus', version: ${JSON.stringify(version)}})); @@ -158,39 +160,37 @@ try { return error; }, ); - assert.equal(result.code, failure || incompatible ? 1 : 0, result.stderr); - if (incompatible) { - assert.ok(result.stderr.includes(targetNode), result.stderr); - assert.equal(existsSync(attempts), false); - console.log('An incompatible target Node pin fails without falling back or starting pnpm.'); - } else { - const directories = readFileSync(attempts, 'utf8').trim().split('\n'); - assert.equal(directories.length, 1); - const installed = directories[0]; - console.log(legacy - ? 'Missing platform metadata preserves the running binary\'s embedded Node default.' - : 'Upgrade installs dependencies with the target metadata\'s Node pin before activation.'); - if (failure) { - assert.equal(existsSync(installed), false); - console.log('A failed dependency install removes the staged version.'); - } else { - assert.equal(realpathSync(current), realpathSync(installed)); - assert.equal(readFileSync(previous, 'utf8'), version); - assert.deepEqual(readFileSync(join(installed, 'node_modules/.vp-install-complete')), - createHash('sha256').update(lockfile).digest()); - console.log('Activation follows successful installation; setup reuses the completion receipt.'); - } + const observations = existsSync(attempts) + ? readFileSync(attempts, 'utf8').trim().split('\n').map(JSON.parse) + : []; + const active = existsSync(current) ? realpathSync(current) : undefined; + const rollback = existsSync(previous) ? readFileSync(previous, 'utf8') : '(missing)'; + console.log(`Upgrade exit: ${result.code}`); + console.log(`stderr: ${result.stderr.trim() + .replaceAll(home, '') + .replaceAll(targetNode, '') || '(empty)'}`); + console.log(`Install attempts: ${observations.length}`); + for (const [index, observation] of observations.entries()) { + const installed = observation.directory; + const receipt = join(installed, 'node_modules/.vp-install-complete'); + const receiptMatches = + existsSync(receipt) && + readFileSync(receipt).equals(createHash('sha256').update(lockfile).digest()); + const runtime = observation.runtime === defaultNode ? '' : observation.runtime; + console.log(`Attempt ${index + 1}: runtime=${runtime}; expected executable=${observation.executableMatches}`); + console.log(` During install: current is original=${observation.currentIsOriginal}; rollback=${observation.rollback}`); + console.log(` Install inputs: frozen=${observation.frozen}; lockfile matches=${observation.lockfileMatches}; dependency matches=${observation.dependencyMatches}`); + console.log(` After upgrade: staged exists=${existsSync(installed)}; receipt matches=${receiptMatches}; current is staged=${active === installed}`); } - if (failure || incompatible) { - assert.equal(realpathSync(current), realpathSync(old)); - assert.equal(readFileSync(previous, 'utf8'), 'rollback-sentinel'); - console.log('The current version and rollback record remain unchanged.'); + console.log(`Current is original: ${active === realpathSync(old)}`); + console.log(`Rollback: ${rollback === version ? '' : rollback}`); + console.log(`Remaining staged directories: ${readdirSync(home).filter((name) => name.startsWith(`${version}+force.`)).length}`); + console.log('Requests:'); + for (const url of requests) { + console.log(` ${url + .replace(/^\/@voidzero-dev\/vite-plus-cli-[^/]+\//, '/@voidzero-dev/vite-plus-cli-/') + .replaceAll(`/${version}`, '/')}`); } - assert.equal(requests.length, 3); - assert.equal(requests[0], '/vite-plus/latest'); - assert.ok(requests[1].endsWith(`/${version}`)); - assert.equal(requests[2], '/platform.tgz'); - console.log('Only the two metadata requests and platform download occur; all stay local.'); } finally { server.close(); server.closeAllConnections(); From da97d074ec256d6d569029a494d6e0c1ecc9698c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Oct 2026 11:06:09 +0900 Subject: [PATCH 17/20] fix(env): keep the LTS fallback separate from installation --- .github/scripts/prewarm-node.sh | 2 +- CONTRIBUTING.md | 2 +- .../snapshots/command_env_list_remote.md | 2 +- .../download_progress/peer-config.mjs | 13 ++++- .../tests/cli_snapshots/redact.rs | 2 +- crates/vp_cli_snapshots/tests/redact_unit.rs | 4 +- .../vp_global_cli/src/commands/env/config.rs | 49 ++++++++----------- .../vp_global_cli/src/commands/env/default.rs | 15 ++++-- .../vp_global_cli/src/commands/env/which.rs | 4 +- crates/vp_global_cli/src/commands/version.rs | 2 +- crates/vp_global_cli/src/js_executor.rs | 10 ++-- crates/vp_global_cli/src/shim/dispatch.rs | 39 --------------- crates/vp_js_runtime/src/lib.rs | 3 -- crates/vp_js_runtime/src/runtime.rs | 49 +++++++++---------- crates/vp_setup/src/install.rs | 12 ++--- crates/vp_setup/src/lib.rs | 3 ++ docs/guide/env.md | 4 +- rfcs/merge-global-and-local-cli.md | 2 +- rfcs/upgrade-command.md | 17 ++++--- 19 files changed, 102 insertions(+), 132 deletions(-) diff --git a/.github/scripts/prewarm-node.sh b/.github/scripts/prewarm-node.sh index 8b6a67646f..ab34a5f2b0 100644 --- a/.github/scripts/prewarm-node.sh +++ b/.github/scripts/prewarm-node.sh @@ -10,7 +10,7 @@ prewarm_node() { time VP_NODE_VERSION="$version" "$vp_bin" node --version || return "$?" } -# Development, CI, and the built-in runtime all use this pin. +# Development, CI, and the installer all use this pin. prewarm_node "$(< .node-version)" prewarm_node lts prewarm_node latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d76d132e93..7ef0bb5d8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ You'll need the following tools installed on your system: - Rust and Cargo - cargo-binstall -The root `.node-version` also supplies the default Node.js version embedded in the CLI. +The root `.node-version` also supplies the Node.js version used to install the global CLI's JavaScript dependencies. The dependency-upgrade workflow keeps this shared development, CI, and installer pin on Node.js 24. If you haven't installed Node.js and pnpm, we recommend using Vite+ to manage them. See [environment management](https://viteplus.dev/guide/env) for details. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index 33c10b14c0..772f8c81c5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -7,7 +7,7 @@ An unconfigured Node.js default explains the effective fallback without claiming ``` VITE+ - The Unified Toolchain for the Web -No default Node.js version configured. Using Vite+'s pinned version (). +No default Node.js version configured. Using latest LTS (). Run 'vp env default ' to set a default. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs index 8a412a4504..a1c2a2a5fe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs @@ -9,6 +9,7 @@ import { mkdirSync, readFileSync, readlinkSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -23,7 +24,8 @@ import { createArchive } from './archive.mjs'; const exec = promisify(execFile); const bypass = process.argv.includes('bypass'); const runnerHome = process.env.VP_HOME; -const cli = join(runnerHome, 'current/node_modules/vite-plus'); +const cli = realpathSync(join(runnerHome, 'current/node_modules/vite-plus')); +const installNode = readFileSync(resolve(cli, '../../.node-version'), 'utf8').trim(); const { generateInstallLockfile, installManifest, INSTALL_PNPM_VERSION } = await import( pathToFileURL(join(cli, 'generate-install-lockfile.ts')).href ); @@ -190,6 +192,15 @@ function createInstallation(name, settings = []) { const nodeBin = join(home, 'js_runtime/node', process.versions.node, 'bin'); mkdirSync(nodeBin, { recursive: true }); symlinkSync(process.execPath, join(nodeBin, 'node')); + // Upgrade uses the embedded installer pin, independently of the setup override. + if (installNode !== process.versions.node) { + const installNodeBin = join(home, 'js_runtime/node', installNode, 'bin'); + mkdirSync(installNodeBin, { recursive: true }); + copyFileSync( + join(runnerHome, 'js_runtime/node', installNode, 'bin/node'), + join(installNodeBin, 'node'), + ); + } cpSync(pnpmRoot, join(home, 'package_manager/pnpm', INSTALL_PNPM_VERSION), { recursive: true }); return { directory, home, ...user }; } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index f8cb4a5a14..453fec079d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -232,7 +232,7 @@ static MANAGED_TEST_VERSION_RE: LazyLock = LazyLock::new(|| { // context so fixture-pinned versions elsewhere stay assertable. static WHICH_NODE_VERSION_RE: LazyLock = LazyLock::new(|| { regex::Regex::new( - r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |No default Node\.js version configured\. Using Vite\+'s pinned version \(|Currently resolves to:\s+|^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, + r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |No default Node\.js version configured\. Using latest LTS \(|Currently resolves to:\s+|^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, ) .unwrap() }); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index a854b26e2c..22b51489d8 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -436,7 +436,7 @@ fn masks_managed_node_versions_in_environment_output() { "Node: 24.18.1\n", "\"nodeVersion\": \"24.18.1\"\n", "✓ Default Node.js version set to lts (currently 24.18.1)\n", - "No default Node.js version configured. Using Vite+'s pinned version (24.18.1).\n", + "No default Node.js version configured. Using latest LTS (24.18.1).\n", " Currently resolves to: 24.18.1\n", "just-a-normal-package@0.0.0 24.18.1 just-a-normal-package\n", "fixture pin: 22.18.0\n", @@ -448,7 +448,7 @@ fn masks_managed_node_versions_in_environment_output() { "Node: \n", "\"nodeVersion\": \"\"\n", "✓ Default Node.js version set to lts (currently )\n", - "No default Node.js version configured. Using Vite+'s pinned version ().\n", + "No default Node.js version configured. Using latest LTS ().\n", " Currently resolves to: \n", "just-a-normal-package@0.0.0 just-a-normal-package\n", "fixture pin: 22.18.0\n", diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index f34fb52154..ba9ffed65b 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -9,8 +9,8 @@ use std::collections::BTreeMap; use serde::{Deserialize, Deserializer, Serialize, de}; use vp_js_runtime::{ - DEFAULT_NODE_VERSION, NodeProvider, VersionSource, is_valid_version, normalize_version, - read_nvmrc_file, read_package_json, resolve_node_version, + NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, + read_package_json, resolve_node_version, }; use vp_pm_cli::PackageManagerType; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -344,7 +344,7 @@ pub async fn delete_session_package_manager(kind: PackageManagerType) -> Result< /// 4. `package.json#engines.node` in current or parent directories /// 5. `.nvmrc` file in current or parent directories /// 6. User default from config.json -/// 7. Node.js release pinned by Vite+ +/// 7. Latest LTS version pub async fn resolve_version(cwd: &AbsolutePath) -> Result { // Session override via environment variable (set by `vp env use`) if let Some(env_version) = vp_shared::EnvConfig::get().node_version.as_deref() { @@ -524,13 +524,14 @@ pub async fn resolve_version_from_files(cwd: &AbsolutePath) -> Result Result { } } None => { - vp_shared::output::print_stdout_line(format_args!( - "No default Node.js version configured. Using Vite+'s pinned version ({}).", - vp_js_runtime::DEFAULT_NODE_VERSION - )); + let provider = vp_js_runtime::NodeProvider::new(); + match provider.resolve_latest_version().await { + Ok(version) => { + vp_shared::output::print_stdout_line(format_args!( + "No default Node.js version configured. Using latest LTS ({version})." + )); + } + Err(_) => vp_shared::output::print_stdout_line(format_args!( + "No default Node.js version configured." + )), + } vp_shared::output::print_stdout_line(format_args!( " Run 'vp env default ' to set a default." )); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index a287197d89..95e6dc1d22 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -266,11 +266,11 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result) -> String { match source { s if s == VERSION_ENV_VAR => format!("{s} (session)"), - "builtin" => "Vite+ default (fallback)".to_string(), + "lts" => "lts (fallback)".to_string(), _ => match source_path { Some(path) => path.as_path().display().to_string(), None => source.to_string(), diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 932d8915ea..c35976392e 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -216,7 +216,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { let node_info = get_node_version_info(&cwd) .await .map(|(v, s)| match s.as_str() { - "builtin" | "lts" | "default" | "system" => format!("v{v}"), + "lts" | "default" | "system" => format!("v{v}"), _ => format!("v{v} ({s})"), }) .unwrap_or(NOT_FOUND.to_string()); diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index a408bc45a0..1f3a6e90e9 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -181,7 +181,7 @@ impl JsExecutor { /// 3. Project sources (.node-version, devEngines.runtime, engines.node, .nvmrc) — /// delegates to `download_runtime_for_project()` for cache-aware resolution /// 4. User default from config.json - /// 5. Node.js release pinned by Vite+ + /// 5. Latest LTS pub async fn ensure_project_runtime( &mut self, project_path: &AbsolutePath, @@ -212,8 +212,8 @@ impl JsExecutor { // 3. Check if project has any *valid* version source. // resolve_node_version returns Some for any non-empty value, // even invalid ones. We must validate before routing to - // download_runtime_for_project, which uses the built-in default when - // all sources are invalid and would skip the user's configured default. + // download_runtime_for_project, which falls to LTS on all-invalid + // and would skip the user's configured default. let has_valid_project_source = has_valid_version_source(project_path).await?; let runtime = if has_valid_project_source { @@ -222,7 +222,7 @@ impl JsExecutor { // and intra-project fallback chain download_runtime_for_project(project_path).await? } else { - // No valid project source: use the configured default, then the built-in default. + // No valid project source, fall back to user default from config, then LTS let resolution = config::resolve_version(project_path).await?; download_runtime(JsRuntimeType::Node, &resolution.version).await? }; @@ -492,7 +492,7 @@ fn local_vite_plus_is_older(local: &str, global: &str) -> bool { /// later call `normalize_version` on the same values. /// /// Returns `false` when all sources are missing or invalid, so the caller -/// can fall through to the user's configured default instead of the built-in default. +/// can fall through to the user's configured default instead of LTS. async fn has_valid_version_source(project_path: &AbsolutePath) -> Result { Ok(config::resolve_project_version_source(project_path, false).await?.is_some()) } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 841c66409c..97555c7642 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1202,14 +1202,6 @@ async fn cached_project_source_still_current( cwd: &AbsolutePathBuf, entry: &ResolveCacheEntry, ) -> Result { - // A CLI upgrade can replace the implicit LTS fallback or update its exact pin. - // Explicit LTS selections use the project or configured-default source instead. - if entry.source == "lts" - || (entry.source == "builtin" && entry.version != vp_js_runtime::DEFAULT_NODE_VERSION) - { - return Ok(false); - } - let Some(current) = config::resolve_project_version_source(cwd, false).await.map_err(|e| format!("{e}"))? else { @@ -1450,37 +1442,6 @@ mod tests { .await; } - #[tokio::test] - async fn test_resolve_with_cache_refreshes_fallback_after_cli_upgrade() { - for (source, version) in [("lts", "26.13.0"), ("builtin", "24.11.0")] { - let temp = TempDir::new().unwrap(); - let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); - std::fs::create_dir(&cwd).unwrap(); - vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_path())], - |_| async { - let mut entry = cache_entry(source, None); - entry.version = version.to_string(); - let mut cache = ResolveCache::default(); - cache.insert(&cwd, entry); - cache.save(&cache::get_cache_path().unwrap()); - - let resolved = resolve_with_cache(&cwd).await.unwrap(); - - assert_eq!(resolved.version, vp_js_runtime::DEFAULT_NODE_VERSION); - assert_eq!(resolved.source, "builtin"); - let saved = ResolveCache::load(&cache::get_cache_path().unwrap()); - assert_eq!( - saved.get(&cwd).unwrap().version, - vp_js_runtime::DEFAULT_NODE_VERSION - ); - }, - ) - .await; - } - } - #[tokio::test] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { let temp = TempDir::new().unwrap(); diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index 393f4377d7..f7f75d092d 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -53,9 +53,6 @@ mod provider; mod providers; mod runtime; -/// Embed the repository's Node.js pin so installed binaries need no source checkout. -pub const DEFAULT_NODE_VERSION: &str = include_str!("../../../.node-version").trim_ascii(); - pub use dev_engines::{ nvmrc_version_spans, parse_node_version_content, read_node_version_file, read_nvmrc_file, }; diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index 969ffe4841..e9618cad49 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -436,7 +436,8 @@ pub async fn resolve_node_version( /// 3. `engines.node` in package.json /// 4. `.nvmrc` file /// -/// If no version source is found, uses the Node.js release pinned by Vite+. +/// If no version source is found, uses the latest installed version from cache, +/// or falls back to the latest LTS version from the network. /// /// When the resolved version from the highest priority source does NOT satisfy /// constraints from lower priority sources, a warning is emitted. @@ -524,8 +525,9 @@ async fn resolve_version_for_project( cache_dir: &AbsolutePath, ) -> Result { if version_req.is_empty() { - // An upstream LTS promotion must not change an implicit runtime selection. - return Ok(crate::DEFAULT_NODE_VERSION.into()); + // No source specified - fetch latest LTS from network + tracing::debug!("No version source specified, fetching latest LTS from network"); + return provider.resolve_latest_version().await; } // Handle LTS aliases (lts/*, lts/iron, lts/-1) @@ -718,12 +720,7 @@ mod tests { then.status(200).body(NODE_INDEX); }) .await; - let mut entries: Vec = serde_json::from_str(NODE_INDEX).unwrap(); - // The pinned fallback does not need to appear in the remote version index. - entries.push(NodeVersionEntry { - version: format!("v{}", crate::DEFAULT_NODE_VERSION).into(), - lts: crate::LtsInfo::NotLts, - }); + let entries: Vec = serde_json::from_str(NODE_INDEX).unwrap(); let mut archives = Vec::new(); for entry in &entries { let version = entry.version.trim_start_matches('v'); @@ -825,16 +822,16 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_no_dev_engines() { - with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { + with_mock_mirror(&["24.11.0"], |temp_path| async move { // Create package.json without devEngines (minified, will use default 2-space indent) let package_json = r#"{"name": "test-project"}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // Should download the release pinned by Vite+ + // Should download the latest LTS assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), "24.11.0"); // Should NOT write .node-version assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); @@ -849,7 +846,7 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_does_not_write_back_when_no_version() { - with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { + with_mock_mirror(&["24.11.0"], |temp_path| async move { // Create package.json with runtime but no version let package_json = r#"{ "name": "test-project", @@ -863,7 +860,7 @@ mod tests { tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), "24.11.0"); // .node-version should NOT be written (auto-write was removed) assert!( @@ -930,13 +927,13 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_no_package_json() { - with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { + with_mock_mirror(&["24.11.0"], |temp_path| async move { // No package.json file let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // Should download the release pinned by Vite+ + // Should download the latest LTS assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), "24.11.0"); // Should NOT write .node-version assert!( @@ -1335,7 +1332,7 @@ mod tests { #[tokio::test] async fn test_invalid_node_version_file_is_ignored() { - with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { + with_mock_mirror(&["24.11.0"], |temp_path| async move { // Create .node-version with invalid version tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); @@ -1343,40 +1340,40 @@ mod tests { let package_json = r#"{"name": "test-project"}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should use the pinned default since .node-version is invalid + // Should fall through to fetch latest LTS since .node-version is invalid let runtime = download_runtime_for_project(&temp_path).await.unwrap(); assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), "24.11.0"); }) .await; } #[tokio::test] async fn test_invalid_engines_node_is_ignored() { - with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { + with_mock_mirror(&["24.11.0"], |temp_path| async move { // Create package.json with invalid engines.node let package_json = r#"{"engines":{"node":"invalid"}}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should use the pinned default since engines.node is invalid + // Should fall through to fetch latest LTS since engines.node is invalid let runtime = download_runtime_for_project(&temp_path).await.unwrap(); assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), "24.11.0"); }) .await; } #[tokio::test] async fn test_invalid_dev_engines_runtime_is_ignored() { - with_mock_mirror(&[crate::DEFAULT_NODE_VERSION], |temp_path| async move { + with_mock_mirror(&["24.11.0"], |temp_path| async move { // Create package.json with invalid devEngines.runtime version let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"invalid"}}}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should use the pinned default since devEngines.runtime is invalid + // Should fall through to fetch latest LTS since devEngines.runtime is invalid let runtime = download_runtime_for_project(&temp_path).await.unwrap(); assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), crate::DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), "24.11.0"); }) .await; } diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 73c1131e10..37115da752 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -13,12 +13,12 @@ use std::{ use flate2::read::GzDecoder; use tar::Archive; -use vp_js_runtime::{DEFAULT_NODE_VERSION, JsRuntimeType, download_runtime}; +use vp_js_runtime::{JsRuntimeType, download_runtime}; use vp_pm_cli::{PackageManagerType, download_package_manager}; use vp_shared::EnvConfig; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use crate::{error::Error, lockfile}; +use crate::{DEFAULT_INSTALL_NODE_VERSION, error::Error, lockfile}; /// Validate that a path from a tarball entry is safe (no path traversal). /// @@ -323,7 +323,7 @@ async fn prepare_install_runtime( version } Some(NodeVersion::Preferred(version)) if supports_pnpm_node(&package, version) => version, - _ => DEFAULT_NODE_VERSION, + _ => DEFAULT_INSTALL_NODE_VERSION, }; let node_runtime = download_runtime(JsRuntimeType::Node, node_version).await.map_err(|error| { @@ -721,7 +721,7 @@ mod tests { } let manifest = pnpm.join("package.json"); tokio::fs::write(&manifest, r#"{"engines":{"node":">=18.12"}}"#).await.unwrap(); - for version in ["22.23.1", DEFAULT_NODE_VERSION] { + for version in ["22.23.1", DEFAULT_INSTALL_NODE_VERSION] { let binary = env .dirs .data @@ -744,13 +744,13 @@ mod tests { index.assert_calls(0); let (runtime, _) = prepare_install_runtime(None).await.unwrap(); - assert_eq!(runtime.version(), DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), DEFAULT_INSTALL_NODE_VERSION); index.assert_calls(0); tokio::fs::write(manifest, r#"{"engines":{"node":">=24"}}"#).await.unwrap(); let (runtime, _) = prepare_install_runtime(Some(NodeVersion::Preferred("22.23.1"))).await.unwrap(); - assert_eq!(runtime.version(), DEFAULT_NODE_VERSION); + assert_eq!(runtime.version(), DEFAULT_INSTALL_NODE_VERSION); index.assert_calls(0); let error = prepare_install_runtime(Some(NodeVersion::Required("22.23.1"))) diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index d81316381f..64d2a16420 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -22,6 +22,9 @@ pub mod lockfile; pub mod platform; pub mod registry; +/// Node.js version used to install the global CLI's dependencies by default. +pub const DEFAULT_INSTALL_NODE_VERSION: &str = include_str!("../../../.node-version").trim_ascii(); + /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 3; diff --git a/docs/guide/env.md b/docs/guide/env.md index b509f19de1..f78cee7a82 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -39,9 +39,9 @@ To select the project's Node.js version, Vite+ checks the current directory firs 3. `engines.node` in `package.json` 4. `.nvmrc` file -If no directory declares a version, Vite+ uses the global default (`vp env default`). If no global default is set, it uses the Node.js version pinned in the Vite+ repository's [`.node-version`](https://github.com/voidzero-dev/vite-plus/blob/main/.node-version). Each Vite+ release embeds this pin at build time, so new Node.js releases do not change its fallback. +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 same embedded 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. +These rules select Node.js for project commands. The installer uses the Node.js version pinned in the Vite+ repository's [`.node-version`](https://github.com/voidzero-dev/vite-plus/blob/main/.node-version). Each release embeds this installation default at build time. 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. diff --git a/rfcs/merge-global-and-local-cli.md b/rfcs/merge-global-and-local-cli.md index 6579a1a2b9..7a8f765d79 100644 --- a/rfcs/merge-global-and-local-cli.md +++ b/rfcs/merge-global-and-local-cli.md @@ -104,7 +104,7 @@ This decouples the `vp` binary from vite-plus's internal file layout. dependency install. pnpm downloads `vite-plus` and its dependencies using the versions and integrity values recorded in that lockfile. The target's platform metadata supplies its Node.js pin through `vpNodeVersion`; older packages - without this field keep the running binary's selection. See the + without this field use the running binary's installation default. See the [upgrade flow](./upgrade-command.md#step-4-extract-and-install) for validation and activation behavior. diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 92ad98657b..422a093bee 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -201,9 +201,10 @@ The archive contains `vp` or `vp.exe`, the bundled packages use this layout; preview metadata supplies a content-addressed URL. The platform manifest also publishes `vpNodeVersion`, the exact Node.js version -from the release checkout's `.node-version`. The binary embeds the same pin at -build time. The existing exact-version metadata request supplies this field; -upgrades do not read the current repository pin or resolve another `latest` tag. +from the release checkout's `.node-version`. The binary embeds the same pin for +dependency installation at build time. The existing exact-version metadata +request supplies this field; upgrades do not read the current repository pin or +resolve another `latest` tag. The upgrade command does not extract the main JavaScript package itself. The subsequent pnpm installation downloads `vite-plus`, NAPI packages, and transitive @@ -278,14 +279,14 @@ recovers it from the verified platform archive before completing a frozen install. See [Global installation lockfile](./merge-global-and-local-cli.md#global-installation-lockfile) for generation, retry, and compatibility details. -If `vpNodeVersion` is absent, the running binary keeps its existing Node.js -selection. A present value must be an exact stable version. Invalid metadata, +If `vpNodeVersion` is absent, the running binary uses its Node.js installation +default. A present value must be an exact stable version. Invalid metadata, an incompatible pnpm runtime, or a failed dependency install stops the upgrade before it changes `current` or the rollback record. Unlike the optional `VP_SETUP_NODE_VERSION` override for initial installation, this release pin -cannot silently fall back to the running binary's default. The running binary -still selects pnpm and executes the installation logic. Clients released before -this metadata support keep their existing behavior. +cannot silently fall back to the running binary's installation default. The +running binary still selects pnpm and executes the installation logic. Clients +released before this metadata support keep their existing behavior. #### Step 5: Version Swap From a92f3032cc7f27db653214b4800e101a5b3d36c5 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Oct 2026 11:11:02 +0900 Subject: [PATCH 18/20] docs(env): remove installer details from runtime selection --- docs/guide/env.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index f78cee7a82..0fb4b8fc49 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -41,8 +41,6 @@ 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 Node.js version pinned in the Vite+ repository's [`.node-version`](https://github.com/voidzero-dev/vite-plus/blob/main/.node-version). Each release embeds this installation default at build time. 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 From 58fa405633a9aed58b86ed5914fd99eec915fc17 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Oct 2026 12:14:56 +0900 Subject: [PATCH 19/20] refactor: clarify Node installation helpers and fixtures --- .github/scripts/upgrade-deps.ts | 8 +++-- .github/workflows/upgrade-deps.yml | 3 +- .../download_progress/upgrade-node.mjs | 29 ++++++++++--------- crates/vp_setup/src/install.rs | 3 +- .../__tests__/publish-native-addons.spec.ts | 3 -- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 2fa62f6527..237b8e5e66 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -1,3 +1,5 @@ +/// + import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -80,7 +82,7 @@ const STABLE_SEMVER_TAG_RE = /^v?\d+\.\d+\.\d+$/; // Vitest major upgrades can change the bundled API, export shims, and CLI // behavior. Advance this only after Vite+ has adapted to the new major. const SUPPORTED_VITEST_MAJOR = 5; -// The default runtime must not follow a new LTS major without a compatibility review. +// Review compatibility before advancing the shared development, CI, and installation pin. const SUPPORTED_NODE_MAJOR = 24; const isFullSha = (s: string): boolean => /^[0-9a-f]{40}$/.test(s); @@ -424,7 +426,7 @@ async function updateVitestVersionConstant(vitestVersion: string): Promise } // Update the shared Node.js pin for development, CI, and installation. -function updateDefaultNodeVersion(nodeVersion: string): void { +function updateNodeVersionFile(nodeVersion: string): void { const filePath = path.join(ROOT, '.node-version'); const oldVersion = fs.readFileSync(filePath, 'utf8').trim(); if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(oldVersion)) { @@ -602,7 +604,7 @@ async function upgradeDependencies(): Promise { oxcParser: oxcParserVersion, }); await updateVitestVersionConstant(vitestVersion); - updateDefaultNodeVersion(nodeVersion); + updateNodeVersionFile(nodeVersion); // Keep the selected versions available if installation or upstream sync fails. writeMetaFiles(); diff --git a/.github/workflows/upgrade-deps.yml b/.github/workflows/upgrade-deps.yml index 41a1b70146..f98375e6fa 100644 --- a/.github/workflows/upgrade-deps.yml +++ b/.github/workflows/upgrade-deps.yml @@ -113,10 +113,11 @@ jobs: ### Background - Upgrade script: `./.github/scripts/upgrade-deps.ts` - - Development, CI, the installer, and fallback Node.js runtime use the + - Development, CI, and global CLI dependency installation use the latest stable `24.x` release pinned in `.node-version`. Keep the exact version selected by the upgrade script. Do not replace it with a moving LTS alias or advance the major version. + Runtime commands keep their latest LTS fallback. - Vitest is capped at the latest stable `5.x` release. Keep the exact version selected by the upgrade script when merging upstream catalogs. Official Vitest packages must use that version. Do not bypass the cap. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs index 73ed932c3f..c1ba0016e7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs @@ -28,10 +28,10 @@ const windows = process.platform === 'win32'; const binaryName = windows ? 'vp.exe' : 'vp'; const runnerHome = process.env.VP_HOME; const cli = realpathSync(join(runnerHome, 'current/node_modules/vite-plus')); -const defaultNode = readFileSync(resolve(cli, '../../.node-version'), 'utf8').trim(); +const installNode = readFileSync(resolve(cli, '../../.node-version'), 'utf8').trim(); const targetNode = '22.23.4'; -assert.notEqual(targetNode, defaultNode); -const expectedNode = legacy ? defaultNode : targetNode; +assert.notEqual(targetNode, installNode); +const expectedNode = legacy ? installNode : targetNode; const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version; const pnpmVersion = /export const INSTALL_PNPM_VERSION = '([^']+)';/.exec( readFileSync(join(cli, 'generate-install-lockfile.ts'), 'utf8'), @@ -66,6 +66,7 @@ if (windows) { payload['package/vp-shim.exe'] = readFileSync(join(runnerHome, 'current/bin/vp-shim.exe')); } const archive = createArchive(payload); +const integrity = `sha512-${createHash('sha512').update(archive).digest('base64')}`; const requests = []; let registry; const server = createServer((request, response) => { @@ -81,7 +82,7 @@ const server = createServer((request, response) => { if (main || platform) { // Only the selected platform package determines the installation runtime. const nodeMetadata = {}; - if (main) nodeMetadata.vpNodeVersion = defaultNode; + if (main) nodeMetadata.vpNodeVersion = installNode; else if (!legacy) nodeMetadata.vpNodeVersion = targetNode; response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ @@ -90,7 +91,7 @@ const server = createServer((request, response) => { ...nodeMetadata, dist: { tarball: `${registry}/platform.tgz`, - integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}`, + integrity, attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, }, })); @@ -103,7 +104,7 @@ await once(server, 'listening'); registry = `http://127.0.0.1:${server.address().port}`; const nodePath = windows ? 'node.exe' : 'bin/node'; -for (const nodeVersion of [defaultNode, targetNode]) { +for (const nodeVersion of [installNode, targetNode]) { const destination = join(home, 'js_runtime/node', nodeVersion, nodePath); mkdirSync(dirname(destination), { recursive: true }); copyFileSync(join(runnerHome, 'js_runtime/node', nodeVersion, nodePath), destination); @@ -111,7 +112,7 @@ for (const nodeVersion of [defaultNode, targetNode]) { const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm'); write(join(pnpm, 'package.json'), JSON.stringify({ version: pnpmVersion, - engines: { node: incompatible ? `>=${defaultNode}` : '*' }, + engines: { node: incompatible ? `>=${installNode}` : '*' }, })); for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) write(join(pnpm, 'bin', shim), 'unused'); write(join(pnpm, 'bin/pnpm.cjs'), ` @@ -165,10 +166,11 @@ try { : []; const active = existsSync(current) ? realpathSync(current) : undefined; const rollback = existsSync(previous) ? readFileSync(previous, 'utf8') : '(missing)'; - console.log(`Upgrade exit: ${result.code}`); - console.log(`stderr: ${result.stderr.trim() + const stderr = result.stderr.trim() .replaceAll(home, '') - .replaceAll(targetNode, '') || '(empty)'}`); + .replaceAll(targetNode, ''); + console.log(`Upgrade exit: ${result.code}`); + console.log(`stderr: ${stderr || '(empty)'}`); console.log(`Install attempts: ${observations.length}`); for (const [index, observation] of observations.entries()) { const installed = observation.directory; @@ -176,7 +178,7 @@ try { const receiptMatches = existsSync(receipt) && readFileSync(receipt).equals(createHash('sha256').update(lockfile).digest()); - const runtime = observation.runtime === defaultNode ? '' : observation.runtime; + const runtime = observation.runtime === installNode ? '' : observation.runtime; console.log(`Attempt ${index + 1}: runtime=${runtime}; expected executable=${observation.executableMatches}`); console.log(` During install: current is original=${observation.currentIsOriginal}; rollback=${observation.rollback}`); console.log(` Install inputs: frozen=${observation.frozen}; lockfile matches=${observation.lockfileMatches}; dependency matches=${observation.dependencyMatches}`); @@ -187,9 +189,10 @@ try { console.log(`Remaining staged directories: ${readdirSync(home).filter((name) => name.startsWith(`${version}+force.`)).length}`); console.log('Requests:'); for (const url of requests) { - console.log(` ${url + const requestPath = url .replace(/^\/@voidzero-dev\/vite-plus-cli-[^/]+\//, '/@voidzero-dev/vite-plus-cli-/') - .replaceAll(`/${version}`, '/')}`); + .replaceAll(`/${version}`, '/'); + console.log(` ${requestPath}`); } } finally { server.close(); diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 37115da752..a55e17296a 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -755,8 +755,7 @@ mod tests { let error = prepare_install_runtime(Some(NodeVersion::Required("22.23.1"))) .await - .err() - .unwrap(); + .unwrap_err(); assert!(error.to_string().contains( "Node.js 22.23.1 required by the target Vite+ release is not supported by pnpm" )); diff --git a/packages/cli/src/__tests__/publish-native-addons.spec.ts b/packages/cli/src/__tests__/publish-native-addons.spec.ts index b64c58dbf6..e424249427 100644 --- a/packages/cli/src/__tests__/publish-native-addons.spec.ts +++ b/packages/cli/src/__tests__/publish-native-addons.spec.ts @@ -53,9 +53,6 @@ beforeEach(() => { vi.clearAllMocks(); vi.stubEnv('VERSION', '1.4.0'); vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.mocked(readFileSync).mockImplementation((path) => - String(path).endsWith('.node-version') ? '24.22.1\n' : 'lockfile', - ); }); afterEach(() => { From 44f7ed9e5af71133fe58f858ccdd17fc3976a000 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Oct 2026 12:34:24 +0900 Subject: [PATCH 20/20] chore: remove redundant Node type reference --- .github/scripts/upgrade-deps.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/scripts/upgrade-deps.ts b/.github/scripts/upgrade-deps.ts index 237b8e5e66..0927b3a9d2 100644 --- a/.github/scripts/upgrade-deps.ts +++ b/.github/scripts/upgrade-deps.ts @@ -1,5 +1,3 @@ -/// - import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path';