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-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..75b7567350 100644
--- a/.github/scripts/__tests__/upgrade-deps.spec.ts
+++ b/.github/scripts/__tests__/upgrade-deps.spec.ts
@@ -41,6 +41,8 @@ test.each([false, true])(
mkdirSync(dirname(join(tempDir, file)), { recursive: true });
copyFileSync(join(root, file), join(tempDir, file));
}
+ const nodeVersionPath = join(tempDir, '.node-version');
+ writeFileSync(nodeVersionPath, '24.9.0\n');
const workspacePath = join(tempDir, 'pnpm-workspace.yaml');
writeFileSync(
workspacePath,
@@ -58,6 +60,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 +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(nodeVersionPath, 'utf8')).toBe('24.10.2\n');
const versions = JSON.parse(readFileSync(join(metaDir, 'versions.json'), 'utf8'));
+ 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 659ed2a6c6..ab34a5f2b0 100644
--- a/.github/scripts/prewarm-node.sh
+++ b/.github/scripts/prewarm-node.sh
@@ -10,8 +10,10 @@ prewarm_node() {
time VP_NODE_VERSION="$version" "$vp_bin" node --version || return "$?"
}
+# Development, CI, and the installer 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/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..0927b3a9d2 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;
+// 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);
@@ -420,6 +423,17 @@ async function updateVitestVersionConstant(vitestVersion: string): Promise
console.log('Updated packages/cli/src/utils/constants.ts');
}
+// Update the shared Node.js pin for development, CI, and installation.
+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)) {
+ throw new Error(`Expected an exact stable Node.js version in ${filePath}`);
+ }
+ fs.writeFileSync(filePath, `${nodeVersion}\n`);
+ recordChange('Node.js', oldVersion, nodeVersion);
+}
+
// ============ Write metadata files for PR description ============
const formatVersion = (v: Change): string => {
if (v.tag) {
@@ -527,6 +541,7 @@ async function upgradeDependencies(): Promise {
console.log('Fetching latest versions…');
const [
+ nodeVersion,
vitestVersion,
tsdownVersion,
lightningcssVersion,
@@ -540,6 +555,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 +571,7 @@ async function upgradeDependencies(): Promise {
getLatestNpmVersion('oxc-parser'),
]);
+ console.log(`Node.js: ${nodeVersion}`);
console.log(`vitest: ${vitestVersion}`);
console.log(`tsdown: ${tsdownVersion}`);
console.log(`lightningcss (from @tsdown/css): ${lightningcssVersion}`);
@@ -585,6 +602,7 @@ async function upgradeDependencies(): Promise {
oxcParser: oxcParserVersion,
});
await updateVitestVersionConstant(vitestVersion);
+ updateNodeVersionFile(nodeVersion);
// Keep the selected versions available if installation or upstream sync fails.
writeMetaFiles();
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..f553e404cd 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
@@ -1114,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"
@@ -1236,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
@@ -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 c3496c07dc..f98375e6fa 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,6 +113,11 @@ jobs:
### Background
- Upgrade script: `./.github/scripts/upgrade-deps.ts`
+ - 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/.node-version b/.node-version
index b8b23ca93c..ce62013c39 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-22.23.4
+24.22.1
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7a8202a5c5..7ef0bb5d8b 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 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.
### macOS / Linux
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_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 },
]
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/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_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/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..ae35b947c7
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata.md
@@ -0,0 +1,20 @@
+# upgrade_node_metadata
+
+## `node upgrade-node.mjs metadata`
+
+```
+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
new file mode 100644
index 0000000000..fac8ba9341
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_failure.md
@@ -0,0 +1,20 @@
+# upgrade_node_metadata_failure
+
+## `node upgrade-node.mjs failure`
+
+```
+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
new file mode 100644
index 0000000000..ad5d79c4a5
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_incompatible.md
@@ -0,0 +1,16 @@
+# upgrade_node_metadata_incompatible
+
+## `node upgrade-node.mjs incompatible`
+
+```
+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
new file mode 100644
index 0000000000..34dcf77dbe
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/upgrade_node_metadata_legacy.md
@@ -0,0 +1,20 @@
+# upgrade_node_metadata_legacy
+
+## `node upgrade-node.mjs legacy`
+
+```
+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
new file mode 100644
index 0000000000..c1ba0016e7
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs
@@ -0,0 +1,203 @@
+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 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 installNode = readFileSync(resolve(cli, '../../.node-version'), 'utf8').trim();
+const targetNode = '22.23.4';
+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'),
+)[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 integrity = `sha512-${createHash('sha512').update(archive).digest('base64')}`;
+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 = installNode;
+ 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,
+ 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 [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);
+}
+const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm');
+write(join(pnpm, 'package.json'), JSON.stringify({
+ version: pnpmVersion,
+ 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'), `
+const fs = require('node:fs');
+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)}}));
+`);
+
+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;
+ },
+ );
+ 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)';
+ const stderr = result.stderr.trim()
+ .replaceAll(home, '')
+ .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;
+ 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 === 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}`);
+ console.log(` After upgrade: staged exists=${existsSync(installed)}; receipt matches=${receiptMatches}; current is staged=${active === installed}`);
+ }
+ 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) {
+ const requestPath = url
+ .replace(/^\/@voidzero-dev\/vite-plus-cli-[^/]+\//, '/@voidzero-dev/vite-plus-cli-/')
+ .replaceAll(`/${version}`, '/');
+ console.log(` ${requestPath}`);
+ }
+} 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_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
diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs
index 136b4cbaa4..ba9ffed65b 100644
--- a/crates/vp_global_cli/src/commands/env/config.rs
+++ b/crates/vp_global_cli/src/commands/env/config.rs
@@ -893,6 +893,36 @@ mod tests {
.await;
}
+ #[tokio::test]
+ async fn test_unconfigured_project_follows_latest_lts() {
+ let temp_dir = TempDir::new().unwrap();
+ let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
+ 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": "v27.1.0", "lts": false},
+ {"version": "v26.13.0", "lts": "Newer LTS"},
+ {"version": "v24.22.1", "lts": "Krypton"}
+ ]));
+ });
+ vp_shared::EnvConfig::with_vars_async(
+ [
+ (env_vars::VP_HOME, temp_dir.path().as_os_str()),
+ (env_vars::VP_NODE_DIST_MIRROR, std::ffi::OsStr::new(&server.base_url())),
+ ],
+ |_| async {
+ let resolution = resolve_version(&temp_path).await.unwrap();
+ assert_eq!(resolution.version, "26.13.0");
+ assert_eq!(resolution.source, "lts");
+ assert!(resolution.is_range);
+ assert!(resolution.source_path.is_none());
+ index.assert_calls(1);
+ },
+ )
+ .await;
+ }
+
#[tokio::test]
async fn test_resolve_version_invalid_node_version_falls_through_to_lts() {
let temp_dir = TempDir::new().unwrap();
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_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