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, 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); } diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 98aa226ce3..a55e17296a 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::{JsRuntimeType, NodeProvider, 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). /// @@ -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,21 +310,23 @@ 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(), + 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}" ) - })? - .to_string(), - }; + .into(), + )); + } + version + } + Some(NodeVersion::Preferred(version)) if supports_pnpm_node(&package, version) => version, + _ => DEFAULT_INSTALL_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 +690,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 +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", "24.11.0"] { + for version in ["22.23.1", DEFAULT_INSTALL_NODE_VERSION] { let binary = env .dirs .data @@ -722,15 +732,34 @@ 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_INSTALL_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); + let (runtime, _) = + prepare_install_runtime(Some(NodeVersion::Preferred("22.23.1"))).await.unwrap(); + assert_eq!(runtime.version(), DEFAULT_INSTALL_NODE_VERSION); + index.assert_calls(0); + + let error = prepare_install_runtime(Some(NodeVersion::Required("22.23.1"))) + .await + .unwrap_err(); + 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/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/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/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..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 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 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. diff --git a/docs/guide/env.md b/docs/guide/env.md index f7ef2eb8e6..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 latest LTS by default. Only [`VP_SETUP_NODE_VERSION`](/guide/global-cli#vp-setup-node-version) changes its dependency-installation runtime; [`setup-vp`](/guide/ci#node-js-during-installation) supplies this override from the CI inputs or project declarations. - `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. ::: tip Using pnpm with Vite+ runtime management diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 820fc36c14..19c133ddf6 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 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 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 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) does not use this variable. #### `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 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. 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 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..e424249427 --- /dev/null +++ b/packages/cli/src/__tests__/publish-native-addons.spec.ts @@ -0,0 +1,110 @@ +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(() => {}); +}); + +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..7a8f765d79 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 use the running binary's installation default. 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..422a093bee 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -200,6 +200,12 @@ 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 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 dependencies using the bundled lockfile. @@ -264,7 +270,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 +279,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 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 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 **Unix (macOS/Linux)** — Atomic symlink swap: