diff --git a/.github/scripts/__tests__/publish-npm-package.spec.ts b/.github/scripts/__tests__/publish-npm-package.spec.ts index 5d31116960..315c1fd16b 100644 --- a/.github/scripts/__tests__/publish-npm-package.spec.ts +++ b/.github/scripts/__tests__/publish-npm-package.spec.ts @@ -113,6 +113,70 @@ describe('publishNpmPackage', () => { expect(fetchImpl.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); }); + + const releasePackages = [ + '@scope/native-linux', + '@scope/native-win32', + '@scope/prompts', + '@scope/core', + 'vite-plus', + '@scope/global-linux', + '@scope/global-win32', + 'create-vite-plus', + ]; + test.each( + releasePackages.flatMap((name) => [ + { name, accepted: false }, + { name, accepted: true }, + ]), + )('resumes publication after $name fails (accepted=$accepted)', async ({ name, accepted }) => { + const published = new Set(); + const uploads: string[] = []; + let interrupted = false; + async function publishRelease() { + for (const packageName of releasePackages) { + const fetchImpl: FetchLike = async () => + response(200, { + // An accepted upload can remain hidden during npm's scanning window. + versions: + published.has(packageName) && !(accepted && packageName === name) + ? { [pkg.version]: {} } + : {}, + }); + const runCommand: PublishCommandRunner = async () => { + uploads.push(packageName); + if (packageName === name && !interrupted) { + interrupted = true; + if (accepted) { + published.add(packageName); + } + return { exitCode: 1, output: 'connection lost' }; + } + if (published.has(packageName)) { + return { + exitCode: 1, + output: 'You cannot publish over the previously published versions', + }; + } + published.add(packageName); + return { exitCode: 0, output: 'published' }; + }; + await publishNpmPackage({ + ...options(fetchImpl, runCommand), + pkg: { name: packageName, version: pkg.version }, + }); + } + } + await expect(publishRelease()).rejects.toThrow(`Failed to publish ${name}@${pkg.version}`); + expect(published.size).toBe(releasePackages.indexOf(name) + Number(accepted)); + await publishRelease(); + expect(published).toEqual(new Set(releasePackages)); + for (const packageName of releasePackages) { + expect(uploads.filter((upload) => upload === packageName)).toHaveLength( + packageName === name ? 2 : 1, + ); + } + }); }); test('recognizes npm immutable-version errors only', () => { diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index d47675067c..ecf620a813 100644 --- a/.github/scripts/test-install-bootstrap.ps1 +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -67,8 +67,10 @@ $originalVpShell = $env:VP_SHELL $fixtureSha = '0123456789012345678901234567890123456789' New-Item -ItemType Directory -Path "$testRoot/package", "$testRoot/tmp", "$testRoot/scripts" | Out-Null Set-Content -LiteralPath "$testRoot/package/vp.exe" -Value 'Payload fixture' +Set-Content -LiteralPath "$testRoot/package/vp-install-lock.yaml" -Value 'Lockfile fixture' @' if ($args.Count -eq 0) { + if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'vp-install-lock.yaml'))) { exit 95 } if (Test-Path Env:VP_SELF_SETUP_SUPPORT_CHECK) { exit 99 } New-Item -ItemType File -Path "$testRoot/binary-invoked" | Out-Null if ($scenario -eq 'failure') { exit 42 } diff --git a/.github/scripts/test-install-bootstrap.sh b/.github/scripts/test-install-bootstrap.sh index 89745f6777..3ca9bb1e49 100644 --- a/.github/scripts/test-install-bootstrap.sh +++ b/.github/scripts/test-install-bootstrap.sh @@ -27,6 +27,7 @@ LEGACY cat > "$test_root/package/vp" <<'BINARY' #!/bin/bash if [ "$#" -eq 0 ]; then + test -f "$(dirname "$0")/vp-install-lock.yaml" || exit 96 test -z "${VP_SELF_SETUP_SUPPORT_CHECK+x}" || exit 99 touch "$test_root/binary-invoked" if [ "$scenario" = failure ]; then exit 42; fi @@ -49,6 +50,7 @@ case "$scenario" in esac BINARY chmod +x "$test_root/package/vp" +printf 'release dependency tree\n' > "$test_root/package/vp-install-lock.yaml" # Ensure the fixture digest contains + and / for the JSON escape cases. for attempt in {1..100}; do printf '\n# Integrity fixture %s\n' "$attempt" >> "$test_root/package/vp" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa82c820cd..d8142f1db2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1352,7 +1352,7 @@ jobs: -v "${{ github.workspace }}:/workspace" \ -w /workspace \ node:22-alpine3.21 sh -ec " - apk add --no-cache bash curl ca-certificates git + apk add --no-cache bash curl ca-certificates git zip unzip # Install pnpm and re-resolve optional dependencies for musl. # The host node_modules has glibc bindings; pnpm store holds both diff --git a/.github/workflows/publish-preview-register.yml b/.github/workflows/publish-preview-register.yml index cbb6c71c34..eb9e5a434f 100644 --- a/.github/workflows/publish-preview-register.yml +++ b/.github/workflows/publish-preview-register.yml @@ -25,6 +25,8 @@ name: Register preview build # - the artifact is treated as hostile bytes, and its download pins run-id to # the triggering run # - no job that installs or executes preview content holds id-token +# - local packages publish first; the PR tag moves only after the global +# packages contain the lockfile generated from the published local CLI # - `publish` of a fork PR is gated on an environment with required # reviewers; a same-repo PR publishes without approval because the # maintainer-applied label already binds consent to the exact built sha @@ -191,6 +193,13 @@ jobs: return; } + if (!artifacts.some((a) => a.name === 'bridge-cli-packages' && !a.expired)) { + core.setFailed( + `run ${process.env.RUN_ID} has no active bridge-cli-packages artifact; re-apply preview-build`, + ); + return; + } + const installerTargets = { installer_x64_id: 'vp-setup-x86_64-pc-windows-msvc', installer_arm64_id: 'vp-setup-aarch64-pc-windows-msvc', @@ -213,12 +222,12 @@ jobs: core.setOutput('head_repo', headRepo); core.info(`authorized PR #${pr.number} from ${headRepo} (fork: ${isFork})`); - publish: - name: Pkg Preview + publish-local: + name: Publish preview local CLI and dependencies needs: authorize if: needs.authorize.outputs.eligible == 'true' runs-on: ubuntu-latest - # Required-reviewer gate on the ONE job that mints a publish credential, + # Required-reviewer gate before either publishing job can run, # applied to FORK PRs only. The `authorize` job proves the PR is open and # labeled; for a fork, the gate proves a human looked at this specific run # before a token existed, and records who. It is the only defense here that @@ -262,7 +271,8 @@ jobs: # consent), or advanced to a new head commit, in which case publishing # this artifact would move the pr- dist-tag BACKWARDS onto an older # commit than the PR now points at. - - name: Re-check authorization before publish + - &check-preview-authorization + name: Re-check authorization before publish uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: PR_NUMBER: ${{ needs.authorize.outputs.pr }} @@ -302,10 +312,9 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - # Validates every archive against the canonical policy, rebuilds each one - # under the commit version, uploads, and registers the ref last so the - # version flips visible atomically. Authenticates with an OIDC token - # minted here; no admin-token, so no bridge secret lives in this repo. + # Publish the local CLI and dependencies under the exact commit version. + # Omit pr-url so this first phase does not move the pr- tag. The bridge + # validates and rebuilds every archive before it becomes installable. - name: Publish to the registry bridge id: bridge uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main @@ -315,15 +324,111 @@ jobs: # version derives from it, never from the artifact's own manifest. sha: ${{ github.event.workflow_run.head_sha }} input-dir: bridge-packages - # SR-2: derived from the API in `authorize`, never from the artifact. - # The bridge maps this to the pr- dist-tag, which VP_PR_VERSION - # resolves, so an attacker-supplied value would change what installs - # for a different PR. - pr-url: ${{ needs.authorize.outputs.pr-url }} # Must match the build workflow's pack step, which validates the same # batch. The bridge's own WORKSPACE_PACKAGES must also allow each name. workspace-packages: vite-plus,create-vite-plus,@voidzero-dev/vite-plus-* + prepare-global: + name: Generate preview installation lockfile + needs: [authorize, publish-local] + runs-on: ubuntu-latest + # Resolve published dependencies without any publishing credentials. Code + # comes from the default branch; the artifacts remain data, never programs. + permissions: + contents: read + actions: read + env: + VERSION: ${{ needs.publish-local.outputs.version }} + steps: + - name: Find prepared global CLI packages from an earlier attempt + id: prepared + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...context.repo, + run_id: context.runId, + }); + core.setOutput('exists', String(artifacts.some( + (artifact) => artifact.name === 'prepared-preview-cli' && !artifact.expired, + ))); + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.prepared.outputs.exists != 'true' + with: + persist-credentials: false + - uses: ./.github/actions/clone + if: steps.prepared.outputs.exists != 'true' + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + if: steps.prepared.outputs.exists != 'true' + - name: Install lockfile generator dependencies + if: steps.prepared.outputs.exists != 'true' + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Download packed global CLI packages + if: steps.prepared.outputs.exists != 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: bridge-cli-packages + path: bridge-cli-packages + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Wait for the published local CLI + if: steps.prepared.outputs.exists != 'true' + run: node .github/scripts/wait-for-npm-packages.ts "vite-plus@${VERSION}" + env: + PUBLISH_REGISTRY: https://registry-bridge.viteplus.dev + + - name: Generate and bundle the pnpm lockfile + if: steps.prepared.outputs.exists != 'true' + run: node packages/cli/generate-preview-install-lockfile.ts bridge-cli-packages + + # Save before any global package is published. Retries reuse the same + # pnpm resolution even if dependency versions change in the registry. + - name: Save prepared global CLI packages + if: steps.prepared.outputs.exists != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: prepared-preview-cli + path: bridge-cli-packages + if-no-files-found: error + retention-days: 7 + compression-level: 0 + + publish: + name: Publish preview global CLI and update PR tag + needs: [authorize, publish-local, prepare-global] + runs-on: ubuntu-latest + # publish-local already passed the required environment approval for forks. + # Re-check authorization below before minting this job's publishing token. + permissions: + id-token: write + actions: read + pull-requests: read + outputs: + version: ${{ steps.bridge.outputs.version }} + steps: + - *check-preview-authorization + + - name: Download prepared global CLI packages + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: prepared-preview-cli + path: bridge-cli-packages + run-id: ${{ github.run_id }} + github-token: ${{ github.token }} + + - name: Publish global CLI packages and update the PR tag + id: bridge + uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main + with: + mode: upload + sha: ${{ github.event.workflow_run.head_sha }} + input-dir: bridge-cli-packages + pr-url: ${{ needs.authorize.outputs.pr-url }} + workspace-packages: '@voidzero-dev/vite-plus-cli-*' + # Read the triggering run's binaries as data using code from the default # branch. Never execute a preview binary in this write-enabled job. comment-binary-size: diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index 6ef043c78d..8454f6c98e 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -174,8 +174,8 @@ jobs: - name: Prepare native addon and CLI binary packages run: node ./packages/cli/publish-native-addons.ts --mode pkg-pr-new - # Pack the locally built package directories (the two preview packages - # and every platform binary) into a workflow artifact. `mode: pack` runs + # Pack the local CLI and its dependencies separately from the global + # binary packages. `mode: pack` runs # `pnpm pack` and nothing else: no network, no credentials. The trusted # workflow validates every archive, rewrites and re-packs it under the commit # version, and uploads it. @@ -196,7 +196,6 @@ jobs: # repo and does not move when the action does. packages: | packages/cli/npm/* - packages/cli/cli-npm/* packages/cli packages/core packages/prompts @@ -205,11 +204,31 @@ jobs: # validates the same batch from main. workspace-packages: vite-plus,create-vite-plus,@voidzero-dev/vite-plus-* + - name: Pack global CLI packages for the registry bridge + uses: voidzero-dev/pkg-pr-registry-bridge@4ca2c31c250c7137ae191bf01a8bb106d30aa106 # main + with: + mode: pack + sha: ${{ github.event.pull_request.head.sha }} + output-dir: bridge-cli-packages + packages: packages/cli/cli-npm/* + workspace-packages: '@voidzero-dev/vite-plus-cli-*' + + - name: Upload packed global CLI packages + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: bridge-cli-packages + path: bridge-cli-packages + overwrite: true + if-no-files-found: error + retention-days: 7 + compression-level: 0 + - name: Upload packed packages uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bridge-packages path: bridge-packages + overwrite: true # A silently empty artifact would make the publishing workflow fail with a # confusing "no packed tarballs" instead of failing here. if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58cb332724..43c9449944 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,6 +60,7 @@ jobs: needs: [check, build-rust, request-approval] if: needs.check.outputs.version_changed == 'true' permissions: + actions: read # restore the lockfile on release job retries contents: write packages: write id-token: write # Required for OIDC @@ -82,6 +83,11 @@ jobs: package-manager-cache: false registry-url: 'https://registry.npmjs.org' + # OIDC dist-tag operations require npm >=11.21.0 and the vite-plus + # trusted publisher's "Allow npm dist-tag" permission. + - name: Install npm with OIDC dist-tag support + run: npm install --global npm@11.21.0 + - name: Install dependencies run: pnpm install @@ -163,12 +169,8 @@ jobs: pattern: vp-release-archive-* merge-multiple: true - - name: Create release archive checksums - working-directory: binary-release - run: sha256sum vp-* > vp-checksums.txt - - name: Prepare and publish native addons - run: node ./packages/cli/publish-native-addons.ts --mode npm + run: node ./packages/cli/publish-native-addons.ts --mode npm --phase native - name: Publish prompts run: >- @@ -191,20 +193,61 @@ jobs: node ./.github/scripts/wait-for-npm-packages.ts "@voidzero-dev/vite-plus-core@${VERSION}" - - name: Publish Vite+ + # Make the exact local CLI version installable without moving latest. + # Bootstrap keeps selecting the previous release until its matching global + # packages and lockfile are available. + - name: Publish local CLI with the staging tag run: >- node ./.github/scripts/publish-npm-package.ts "vite-plus@${VERSION}" -- - pnpm publish --filter=./packages/cli --tag latest --access public --no-git-checks + pnpm publish --filter=./packages/cli --tag staging --access public --no-git-checks - # Downstream jobs build Docker images by installing this release from npm. - - name: Wait for published packages to propagate + - name: Wait for the local CLI to propagate run: >- node ./.github/scripts/wait-for-npm-packages.ts "vite-plus@${VERSION}" "@voidzero-dev/vite-plus-prompts@${VERSION}" + # Keep the first pnpm resolution when retrying a partially published release. + - name: Restore installation lockfile from an earlier attempt + id: install-lockfile + env: + GH_TOKEN: ${{ github.token }} + run: | + artifact_id=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts" \ + --jq '.artifacts[] | select(.name == "installation-lockfile" and .expired == false) | .id') + if [ -n "$artifact_id" ]; then + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > "$RUNNER_TEMP/installation-lockfile.zip" + unzip -o "$RUNNER_TEMP/installation-lockfile.zip" -d binary-release + echo "restored=true" >> "$GITHUB_OUTPUT" + fi + + - name: Generate global installation lockfile + run: node ./packages/cli/generate-install-lockfile.ts + + # Save before publishing any platform package, including while npm still + # hides accepted versions during scanning. Retries must not resolve again. + - name: Save installation lockfile for release retries + if: steps.install-lockfile.outputs.restored != 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: installation-lockfile + path: binary-release/vp-install-lock.yaml + if-no-files-found: error + + - name: Publish CLI binaries with the installation lockfile + run: node ./packages/cli/publish-native-addons.ts --mode npm --phase cli + + # The preceding step waits for every global package to propagate. + # Promote the existing version; never repack or republish the local CLI. + - name: Promote the local CLI to latest + run: npm dist-tag add "vite-plus@${VERSION}" latest + + - name: Create release archive checksums + working-directory: binary-release + run: sha256sum vp-* > vp-checksums.txt + # create-vite-plus pins vite-plus via workspace:* (rewritten to this exact # version), so it publishes only after vite-plus is installable. - name: Publish create-vite-plus @@ -214,7 +257,26 @@ jobs: -- pnpm publish --filter=./packages/create-vite-plus --tag latest --access public --no-git-checks + # Publishing can succeed even if the runner loses the response. Do not + # replace assets on a published release, including an immutable release. + - name: Check whether the GitHub release is already published + id: github-release + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + try { + const { data } = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag: `v${process.env.VERSION}`, + }); + core.setOutput('published', String(!data.draft)); + } catch (error) { + if (error.status !== 404) throw error; + core.setOutput('published', 'false'); + } + - name: Create release body + if: steps.github-release.outputs.published != 'true' env: REPOSITORY: ${{ github.repository }} COMMIT_SHA: ${{ github.sha }} @@ -248,6 +310,7 @@ jobs: EOF - name: Create GitHub Release + if: steps.github-release.outputs.published != 'true' uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 id: release with: @@ -258,13 +321,17 @@ jobs: name: vite-plus v${{ env.VERSION }} tag_name: v${{ env.VERSION }} target_commitish: ${{ github.sha }} + overwrite_files: true + fail_on_unmatched_files: true files: | installer-release/vp-setup-*.exe binary-release/vp-*.tar.gz binary-release/vp-*.zip binary-release/vp-checksums.txt + binary-release/vp-install-lock.yaml - name: Publish GitHub Release + if: steps.github-release.outputs.published != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release edit "v${VERSION}" --draft=false --repo "${{ github.repository }}" diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml index 07a84238d6..aca6f03482 100644 --- a/.github/workflows/reusable-release-build.yml +++ b/.github/workflows/reusable-release-build.yml @@ -73,6 +73,7 @@ jobs: # 0.0.0-commit. for preview builds). Anchored to the line-start # `version = "..."` so crate dependency versions are never touched. for f in crates/vp_global_cli/Cargo.toml packages/cli/binding/Cargo.toml; do + # shellcheck disable=SC2016 # JavaScript expands process.env.VERSION. node -e 'const fs=require("fs"),f=process.argv[1];fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(/^version = "[^"]*"/m,`version = "${process.env.VERSION}"`))' "$f" done # Keep the workspace package entries in Cargo.lock consistent with @@ -121,6 +122,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vite-plus-native-${{ matrix.settings.target }} + overwrite: true path: ./packages/cli/binding/*.node if-no-files-found: error @@ -128,6 +130,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vp-global-cli-${{ matrix.settings.target }} + overwrite: true path: | ./target/${{ matrix.settings.target }}/release/vp ./target/${{ matrix.settings.target }}/release/vp.exe @@ -192,6 +195,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vp-release-archive-${{ matrix.settings.target }} + overwrite: true path: | ./target/${{ matrix.settings.target }}/release/vp-*.tar.gz ./target/${{ matrix.settings.target }}/release/vp-*.zip @@ -202,6 +206,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vp-setup-${{ matrix.settings.target }} + overwrite: true path: ./target/${{ matrix.settings.target }}/release/vp-setup.exe if-no-files-found: error @@ -215,6 +220,7 @@ jobs: if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} with: name: core + overwrite: true path: ./packages/core/dist if-no-files-found: error @@ -223,6 +229,7 @@ jobs: if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} with: name: prompts + overwrite: true path: ./packages/prompts/dist if-no-files-found: error @@ -231,6 +238,7 @@ jobs: if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} with: name: cli + overwrite: true path: ./packages/cli/dist if-no-files-found: error @@ -239,6 +247,7 @@ jobs: if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} with: name: cli-docs + overwrite: true path: ./packages/cli/docs if-no-files-found: error @@ -247,6 +256,7 @@ jobs: if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} with: name: licenses + overwrite: true path: | ./packages/core/LICENSE ./packages/cli/LICENSE diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml index 2b055aa706..887e0cf973 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml @@ -1,6 +1,8 @@ [[case]] name = "command_self_setup" vp = "global" +# The runner provides local dependencies; this case checks setup and dispatch. +env = { VP_SKIP_DEPS_INSTALL = "1" } steps = [ { argv = ["vpt", "rm", "$VP_HOME/current/bin/.vp-setup-complete"], snapshot = false }, { argv = ["vp", "--help"], envs = [["VP_SELF_SETUP_SUPPORT_CHECK", "1"]], comment = "The capability probe does not perform setup" }, @@ -26,6 +28,7 @@ steps = [ [[case]] name = "command_self_setup_retry" vp = "global" +env = { VP_SKIP_DEPS_INSTALL = "1" } skip-platforms = ["windows"] steps = [ { argv = ["vpt", "rm", "$VP_HOME/current/bin/.vp-setup-complete", "$VP_HOME/env"], snapshot = false }, 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 9f923d5bd6..99d83729f8 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 @@ -10,14 +10,19 @@ import { readdirSync, rmSync, symlinkSync, + writeFileSync, } from 'node:fs'; import { createServer } from 'node:http'; import { join, resolve } from 'node:path'; + import { createArchive } from './archive.mjs'; const interactive = process.argv.includes('interactive'); const failure = process.argv.includes('failure'); const resolutionRetry = process.argv.includes('resolution-retry'); +const dependencyRetry = process.argv.includes('dependency-retry'); +const upgradeHandoff = process.argv.includes('upgrade-handoff'); +const captureStderr = resolutionRetry || dependencyRetry || upgradeHandoff; if (interactive) process.stdin.setRawMode(true); const home = resolve('home'); const user = resolve('user'); @@ -29,21 +34,26 @@ for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recurs // Use the runner's real Node for the mock pnpm program, without a network download. 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 pnpmArchive = createArchive({ 'package/package.json': JSON.stringify({ name: 'pnpm', - version: '10.33.0', + version: '10.34.7', bin: { pnpm: 'bin/pnpm.cjs' }, }), 'package/bin/pnpm.cjs': ` const fs = require('node:fs'); + const assert = require('node:assert/strict'); + assert.ok(process.argv.includes('--frozen-lockfile')); + assert.equal(fs.readFileSync('pnpm-lock.yaml', 'utf8'), ${JSON.stringify(lockfile)}); console.log('pnpm captured stdout'); console.error('pnpm captured stderr'); fetch(process.env.npm_config_registry + '/install').then(response => { - if (!response.ok) process.exit(17); fs.mkdirSync('node_modules/vite-plus', { recursive: true }); fs.writeFileSync('node_modules/vite-plus/package.json', '{"name":"vite-plus"}'); + if (!response.ok) process.exit(17); }); `, }); @@ -75,7 +85,10 @@ async function handleRequest(request, response) { response.end(pnpmArchive); } else if (request.url === '/install') { await checkpoint('dependencies'); - response.writeHead(failure ? 500 : 200).end(); + const fail = + failure || + (dependencyRetry && phases.filter((phase) => phase === 'dependencies').length === 1); + response.writeHead(fail ? 500 : 200).end(); } else { response.writeHead(404).end(); } @@ -86,9 +99,9 @@ server.listen(0, '127.0.0.1'); await once(server, 'listening'); const mirror = `http://127.0.0.1:${server.address().port}`; -async function runInstaller() { - const child = spawn(join(external, 'vp'), [], { - stdio: ['ignore', 'pipe', resolutionRetry ? 'pipe' : 'inherit'], +async function runInstaller(binary = join(external, 'vp'), args = []) { + const child = spawn(binary, args, { + stdio: ['ignore', 'pipe', captureStderr ? 'pipe' : 'inherit'], env: { ...process.env, HOME: user, @@ -97,7 +110,7 @@ async function runInstaller() { VP_NODE_DIST_MIRROR: mirror, npm_config_registry: mirror, NPM_CONFIG_REGISTRY: mirror, - VP_SELF_SETUP_SHELL: 'sh', + VP_SELF_SETUP_SHELL: args.length === 0 ? 'sh' : undefined, VP_SELF_SETUP_NO_MODIFY_PATH: '1', VP_NODE_MANAGER: 'no', VP_SHELL: 'zsh', @@ -122,7 +135,7 @@ async function runInstaller() { try { console.log('Before installation: preserve this output.'); let result = await runInstaller(); - if (resolutionRetry) { + if (resolutionRetry || dependencyRetry) { assert.equal(result.code, 1, result.stderr); assert.equal(result.stdout, ''); const installed = readdirSync(home) @@ -130,27 +143,60 @@ try { .filter((directory) => existsSync(join(directory, 'node_modules/vite-plus/package.json'))); assert.equal(installed.length, 1); const marker = join(installed[0], 'bin/.vp-setup-complete'); + const receipt = join(installed[0], 'node_modules/.vp-install-complete'); assert.equal(existsSync(marker), false); + assert.equal(existsSync(receipt), resolutionRetry); assert.equal(existsSync(resolutionFile), false); assert.deepEqual(phases, ['download', 'prepare', 'dependencies']); - console.log('A failed resolution-file write leaves dependencies installed and setup incomplete.'); + console.log( + resolutionRetry + ? 'A failed resolution-file write leaves dependencies installed and setup incomplete.' + : 'A failed dependency install leaves package.json present, without a completion receipt.', + ); - mkdirSync(resolutionDir); + if (resolutionRetry) mkdirSync(resolutionDir); result = await runInstaller(); assert.equal(result.code, 0, result.stderr); - assert.equal(readFileSync(resolutionFile, 'utf8'), '20.18.0'); + if (resolutionRetry) assert.equal(readFileSync(resolutionFile, 'utf8'), '20.18.0'); + assert.equal(existsSync(marker), true); + assert.equal(existsSync(receipt), true); + console.log( + resolutionRetry + ? 'Retry reuses the completed frozen install and returns the requested Node version.' + : 'Retry completes the frozen dependency install before marking setup complete.', + ); + } + if (upgradeHandoff) { + assert.equal(result.code, 0, result.stderr); + const current = join(home, 'current'); + const marker = join(current, 'bin/.vp-setup-complete'); + assert.equal(existsSync(join(current, 'node_modules/.vp-install-complete')), true); + // Upgrade completes the shared dependency installer, then clears only the setup marker. + rmSync(marker); + result = await runInstaller(join(current, 'bin/vp'), ['--help']); + assert.equal(result.code, 0, result.stderr); assert.equal(existsSync(marker), true); - console.log('Retry returns the requested Node version without installing dependencies again.'); + console.log( + 'Upgrade handoff completes self-setup without another runtime lookup or dependency install.', + ); } const { code, stdout, stderr } = result; assert.equal(code, failure ? 1 : 0, stderr); - assert.deepEqual(phases, ['download', 'prepare', 'dependencies']); + assert.deepEqual( + phases, + dependencyRetry + ? ['download', 'prepare', 'dependencies', 'dependencies'] + : ['download', 'prepare', 'dependencies'], + ); if (failure) { assert.equal(stdout, ''); const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); assert.ok(log.includes('pnpm captured stdout')); assert.ok(log.includes('pnpm captured stderr')); console.log('Failure log preserves pnpm stdout and stderr.'); + } else if (upgradeHandoff) { + assert.ok(stdout.includes('Usage:')); + console.log('The first command runs after self-setup completes.'); } else { assert.deepEqual( stdout 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 new file mode 100644 index 0000000000..8a412a4504 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs @@ -0,0 +1,415 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +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 { generateInstallLockfile, installManifest, INSTALL_PNPM_VERSION } = await import( + pathToFileURL(join(cli, 'generate-install-lockfile.ts')).href +); +const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version; + +// Only the pinned pnpm distribution uses the runner's cached registry. Real pnpm +// is necessary to exercise configuration precedence and frozen-lockfile checks. +await exec(join(runnerHome, 'bin/vp'), ['env', 'install', `pnpm@${INSTALL_PNPM_VERSION}`]); +const pnpmRoot = join(runnerHome, 'package_manager/pnpm', INSTALL_PNPM_VERSION); +const pnpm = join(pnpmRoot, 'pnpm/bin/pnpm.cjs'); +assert.equal( + (await exec(process.execPath, [pnpm, '--version'])).stdout.trim(), + INSTALL_PNPM_VERSION, +); + +const root = resolve('peer-config'); +const external = join(root, 'external'); +mkdirSync(external, { recursive: true }); +const conflicts = [ + { option: 'auto-install-peers', value: 'false', setting: 'autoInstallPeers' }, + { option: 'peers-suffix-max-length', value: '100', setting: 'peersSuffixMaxLength' }, + { option: 'exclude-links-from-lockfile', value: 'true', setting: 'excludeLinksFromLockfile' }, + { option: 'dedupe-peers', value: 'true', setting: 'dedupePeers' }, + { option: 'inject-workspace-packages', value: 'true', setting: 'injectWorkspacePackages' }, +]; +const disabledLockfile = { option: 'lockfile', value: 'false' }; + +const requests = []; +const packages = new Map( + [ + { name: 'vite-plus', version, peerDependencies: { 'fixture-peer': '1.0.0' } }, + { name: 'fixture-peer', version: '1.0.0' }, + ].map((manifest) => { + const archive = createArchive({ 'package/package.json': JSON.stringify(manifest) }); + return [manifest.name, { manifest, archive }]; + }), +); +let registry; +let upgradeArchive; +let upgradeIntegrity; +const server = createServer((request, response) => { + // Native upgrade metadata is separate from the authenticated pnpm fixture registry. + if (upgradeArchive) { + if (request.url === '/platform.tgz') { + response.end(upgradeArchive); + return; + } + if (request.url === '/index.json') { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify([{ version: process.version, lts: 'Fixture' }])); + return; + } + if ( + request.url === `/vite-plus/${version}` || + request.url.startsWith('/@voidzero-dev/vite-plus-cli-') + ) { + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + version, + vpInstallLockfile: true, + dist: { + tarball: `${registry}/platform.tgz`, + integrity: upgradeIntegrity, + attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + }, + }), + ); + return; + } + } + requests.push({ url: request.url, authorization: request.headers.authorization }); + if (request.headers.authorization !== 'Bearer fixture-token') { + response.writeHead(401).end(); + return; + } + const name = request.url.slice(1).split('/')[0]; + const pkg = packages.get(name); + if (!pkg) { + response.writeHead(404).end(); + return; + } + if (request.url.endsWith('.tgz')) { + response.end(pkg.archive); + return; + } + const manifest = { + ...pkg.manifest, + dist: { + tarball: `${registry}/${name}/-/${name}-${pkg.manifest.version}.tgz`, + integrity: `sha512-${createHash('sha512').update(pkg.archive).digest('base64')}`, + }, + }; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + name, + 'dist-tags': { latest: manifest.version }, + versions: { [manifest.version]: manifest }, + }), + ); +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +registry = `http://127.0.0.1:${server.address().port}`; + +function configureUser(user, settings = []) { + mkdirSync(user, { recursive: true }); + const userconfig = join(user, '.npmrc'); + const configuration = [ + `registry=${registry}/`, + `//127.0.0.1:${server.address().port}/:_authToken=fixture-token`, + `store-dir=${join(user, 'store')}`, + `cache-dir=${join(user, 'cache')}`, + ...settings.map(({ option, value }) => `${option}=${value}`), + '', + ].join('\n'); + writeFileSync(userconfig, configuration); + const env = { + ...process.env, + HOME: user, + CI: 'true', + npm_config_registry: undefined, + NPM_CONFIG_REGISTRY: undefined, + npm_config_userconfig: userconfig, + NPM_CONFIG_USERCONFIG: userconfig, + VP_SETUP_SKIP_INSTALL_LOCKFILE: undefined, + }; + // Keep the test independent of the host's values for these settings. + for (const { option } of [...conflicts, disabledLockfile]) { + const key = `npm_config_${option.replaceAll('-', '_')}`; + delete env[key]; + delete env[key.toUpperCase()]; + } + return { userconfig, configuration, env }; +} + +function conflictEnvironment(settings) { + return Object.fromEntries( + settings.flatMap(({ option, value }) => { + const key = `npm_config_${option.replaceAll('-', '_')}`; + return [ + [key, value], + [key.toUpperCase(), value], + ]; + }), + ); +} + +function runPnpm(cwd, args, env) { + return exec(process.execPath, [pnpm, ...args], { cwd, env }); +} + +function assertDependencyDownloads(since) { + const downloads = requests.slice(since); + assert.ok(downloads.some(({ url }) => url.endsWith(`vite-plus-${version}.tgz`))); + assert.ok(downloads.some(({ url }) => url.endsWith('fixture-peer-1.0.0.tgz'))); +} + +function createInstallation(name, settings = []) { + const directory = join(root, name); + const home = join(directory, 'home'); + const user = configureUser(join(directory, 'user'), settings); + const nodeBin = join(home, 'js_runtime/node', process.versions.node, 'bin'); + mkdirSync(nodeBin, { recursive: true }); + symlinkSync(process.execPath, join(nodeBin, 'node')); + cpSync(pnpmRoot, join(home, 'package_manager/pnpm', INSTALL_PNPM_VERSION), { recursive: true }); + return { directory, home, ...user }; +} + +async function runSetup(installation, overrides = {}, binary = join(external, 'vp'), args = []) { + const { directory, home, env } = installation; + return exec(binary, args, { + cwd: directory, + env: { + ...env, + VP_HOME: home, + VP_NODE_DIST_MIRROR: registry, + VP_SELF_SETUP_SHELL: args.length === 0 ? 'sh' : undefined, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_SKIP_DEPS_INSTALL: '', + VP_NODE_MANAGER: 'no', + VP_PM_MANAGER: 'no', + VP_SETUP_NODE_VERSION: process.versions.node, + ...overrides, + }, + }).catch((error) => { + const log = join(home, 'upgrade.log'); + if (existsSync(log)) error.message += `\n${readFileSync(log, 'utf8')}`; + throw error; + }); +} + +async function testBypass(lockfile) { + const installation = createInstallation('bypass'); + const brokenLockfile = 'lockfileVersion: [\n'; + const companion = join(external, 'vp-install-lock.yaml'); + writeFileSync(companion, brokenLockfile); + // Only the exact value "1" enables the escape hatch. + for (const value of [undefined, '', '0', 'true']) { + await assert.rejects( + runSetup(installation, { VP_SETUP_SKIP_INSTALL_LOCKFILE: value }), + (error) => error.code === 1 && /ERR_PNPM_BROKEN_LOCKFILE/.test(error.message), + ); + } + console.log('A broken lockfile still fails unless VP_SETUP_SKIP_INSTALL_LOCKFILE is exactly 1.'); + + const target = join(installation.home, version); + // Keep the broken working copy, but remove both companions. Recovery would hit a 404. + rmSync(companion); + rmSync(join(target, 'bin/vp-install-lock.yaml')); + const override = { + VP_SETUP_SKIP_INSTALL_LOCKFILE: '1', + npm_config_frozen_lockfile: 'true', + NPM_CONFIG_FROZEN_LOCKFILE: 'true', + npm_config_lockfile: 'true', + NPM_CONFIG_LOCKFILE: 'true', + }; + const before = requests.length; + const result = await runSetup(installation, override); + assert.match( + result.stderr, + /VP_SETUP_SKIP_INSTALL_LOCKFILE=1: ignoring the installation lockfile/, + ); + const current = join(installation.home, 'current'); + const receipt = join(current, 'node_modules/.vp-install-complete'); + const marker = join(current, 'bin/.vp-setup-complete'); + assert.equal(readFileSync(join(current, 'pnpm-lock.yaml'), 'utf8'), brokenLockfile); + assert.equal(existsSync(join(current, 'bin/vp-install-lock.yaml')), false); + assert.equal( + JSON.parse(readFileSync(join(current, 'node_modules/vite-plus/package.json'), 'utf8')).version, + version, + ); + assert.equal(readFileSync(receipt, 'utf8'), 'unlocked'); + assertDependencyDownloads(before); + assert.equal(readFileSync(installation.userconfig, 'utf8'), installation.configuration); + console.log( + 'The bypass warns, ignores broken or missing lockfiles, and installs the exact CLI version.', + ); + console.log( + 'User registry and authentication still work; the completion receipt is explicitly unlocked.', + ); + + // Exercise the real upgrade path with an archive that advertises a missing companion. + upgradeArchive = createArchive({ + 'package/package.json': JSON.stringify({ version, vpInstallLockfile: true }), + 'package/vp': readFileSync(join(runnerHome, 'bin/vp')), + }); + upgradeIntegrity = `sha512-${createHash('sha512').update(upgradeArchive).digest('base64')}`; + const upgradeArgs = ['upgrade', version, '--force', '--registry', registry]; + const previousTarget = readlinkSync(current); + await assert.rejects( + runSetup(installation, {}, join(current, 'bin/vp'), upgradeArgs), + (error) => + error.code === 1 && /Platform package is missing vp-install-lock.yaml/.test(error.message), + ); + assert.equal(readlinkSync(current), previousTarget); + const upgradeResult = await runSetup( + installation, + override, + join(current, 'bin/vp'), + upgradeArgs, + ); + assert.match( + upgradeResult.stderr, + /VP_SETUP_SKIP_INSTALL_LOCKFILE=1: ignoring the installation lockfile/, + ); + assert.notEqual(readlinkSync(current), previousTarget); + assert.equal(existsSync(join(current, 'bin/vp-install-lock.yaml')), false); + assert.equal(existsSync(marker), true); + assert.equal(readFileSync(receipt, 'utf8'), 'unlocked'); + await runSetup(installation, {}, join(current, 'bin/vp'), ['--version']); + console.log('Upgrade rejects a missing companion by default and succeeds with the bypass.'); + console.log('Upgrade handoff completes setup; later commands work without the override.'); + + // A subsequent normal setup must not accept an unlocked receipt as frozen. + writeFileSync(join(current, 'bin/vp-install-lock.yaml'), lockfile); + rmSync(marker); + await runSetup(installation, {}, join(current, 'bin/vp')); + assert.equal(readFileSync(join(current, 'pnpm-lock.yaml'), 'utf8'), lockfile); + assert.deepEqual(readFileSync(receipt), createHash('sha256').update(lockfile).digest()); + console.log('Normal setup restores the published lockfile and records a fresh frozen install.'); + + const failed = createInstallation('failed-bypass'); + const failedTarget = join(failed.home, version); + const failedReceipt = join(failedTarget, 'node_modules/.vp-install-complete'); + mkdirSync(join(failedTarget, 'node_modules'), { recursive: true }); + writeFileSync(failedReceipt, createHash('sha256').update(lockfile).digest()); + // A fresh offline store makes resolution fail without any external requests or retries. + await assert.rejects( + runSetup(failed, { ...override, npm_config_offline: 'true', NPM_CONFIG_OFFLINE: 'true' }), + (error) => error.code === 1 && /ERR_PNPM_NO_OFFLINE_META/.test(error.message), + ); + assert.equal(existsSync(failedReceipt), false); + assert.equal(existsSync(join(failedTarget, 'bin/.vp-setup-complete')), false); + console.log('A failed bypass clears any stale receipt and leaves setup incomplete.'); +} + +async function testInstallConfig(lockfile) { + for (const conflict of [...conflicts, disabledLockfile]) { + const installation = createInstallation(conflict.option, [conflict]); + const { directory, home, userconfig, configuration, env } = installation; + const control = join(directory, 'control'); + mkdirSync(control); + writeFileSync(join(control, 'package.json'), JSON.stringify(installManifest(version))); + writeFileSync(join(control, '.npmrc'), 'minimum-release-age=0\n'); + writeFileSync(join(control, 'pnpm-lock.yaml'), lockfile); + await assert.rejects( + runPnpm(control, ['install', '--ignore-workspace', '--frozen-lockfile'], env), + (error) => { + if (error.code !== 1) return false; + const output = error.stdout + error.stderr; + if (conflict.option === 'lockfile') { + return /ERR_PNPM_NO_LOCKFILE/.test(output); + } + return ( + /ERR_PNPM_LOCKFILE_CONFIG_MISMATCH/.test(output) && + output.includes(`settings.${conflict.setting}`) + ); + }, + ); + assert.equal(readFileSync(join(control, 'pnpm-lock.yaml'), 'utf8'), lockfile); + console.log(`Without the installer override, real pnpm rejects ${conflict.option}.`); + + const before = requests.length; + await runSetup(installation, conflictEnvironment([conflict])); + const current = join(home, 'current'); + assert.equal(readFileSync(join(current, 'pnpm-lock.yaml'), 'utf8'), lockfile); + assert.deepEqual( + readFileSync(join(current, 'node_modules/.vp-install-complete')), + createHash('sha256').update(lockfile).digest(), + ); + assert.equal(existsSync(join(current, 'node_modules/vite-plus/package.json')), true); + assertDependencyDownloads(before); + assert.equal(readFileSync(userconfig, 'utf8'), configuration); + console.log(`Frozen setup overrides ${conflict.option} in user and environment configuration.`); + } + console.log( + 'All installs fetch the CLI and its peer using the user registry and authentication.', + ); + console.log('The published lockfile and user configuration remain unchanged.'); +} + +try { + const generationUser = join(root, 'generation-user'); + const defaults = configureUser(generationUser); + const lockfile = await generateInstallLockfile( + version, + registry, + async (cwd, args, overrides) => { + await runPnpm(cwd, args, { ...defaults.env, ...overrides }); + }, + ); + assert.match(lockfile, /autoInstallPeers: true/); + assert.match(lockfile, /fixture-peer@1\.0\.0/); + // pnpm omits its numeric default of 1000; a CLI string would be recorded as '1000'. + assert.doesNotMatch(lockfile, /peersSuffixMaxLength:/); + const conflictingGeneration = configureUser(generationUser, conflicts); + assert.equal( + await generateInstallLockfile(version, registry, async (cwd, args, overrides) => { + await runPnpm(cwd, args, { + ...conflictingGeneration.env, + ...conflictEnvironment(conflicts), + ...overrides, + }); + }), + lockfile, + ); + assert.equal( + readFileSync(conflictingGeneration.userconfig, 'utf8'), + conflictingGeneration.configuration, + ); + console.log( + 'The generator pins all frozen-lockfile settings despite user and environment values.', + ); + + copyFileSync(join(runnerHome, 'bin/vp'), join(external, 'vp')); + writeFileSync(join(external, 'vp-install-lock.yaml'), lockfile); + if (bypass) { + await testBypass(lockfile); + } else { + await testInstallConfig(lockfile); + } + assert.ok(requests.every(({ authorization }) => authorization === 'Bearer fixture-token')); +} finally { + server.close(); + server.closeAllConnections(); + rmSync(root, { recursive: true, force: true }); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml index 2c5c312b2f..4af0391c5a 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 @@ -55,6 +55,30 @@ vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "install.mjs", "resolution-retry"], tty = false }] +[[case]] +name = "installation_dependency_retry" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "dependency-retry"], tty = false }] + +[[case]] +name = "installation_upgrade_handoff" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "upgrade-handoff"], tty = false }] + +[[case]] +name = "installation_peer_config" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "peer-config.mjs"], tty = false }] + +[[case]] +name = "installation_lockfile_bypass" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "peer-config.mjs", "bypass"], tty = false }] + [[case]] name = "installation_progress_non_tty" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_dependency_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_dependency_retry.md new file mode 100644 index 0000000000..c97fa769e4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_dependency_retry.md @@ -0,0 +1,11 @@ +# installation_dependency_retry + +## `node install.mjs dependency-retry` + +``` +Before installation: preserve this output. +A failed dependency install leaves package.json present, without a completion receipt. +Retry completes the frozen dependency install before marking setup complete. +Bootstrap stdout contains only shell assignments. +After installation. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_lockfile_bypass.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_lockfile_bypass.md new file mode 100644 index 0000000000..605455a174 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_lockfile_bypass.md @@ -0,0 +1,14 @@ +# installation_lockfile_bypass + +## `node peer-config.mjs bypass` + +``` +The generator pins all frozen-lockfile settings despite user and environment values. +A broken lockfile still fails unless VP_SETUP_SKIP_INSTALL_LOCKFILE is exactly 1. +The bypass warns, ignores broken or missing lockfiles, and installs the exact CLI version. +User registry and authentication still work; the completion receipt is explicitly unlocked. +Upgrade rejects a missing companion by default and succeeds with the bypass. +Upgrade handoff completes setup; later commands work without the override. +Normal setup restores the published lockfile and records a fresh frozen install. +A failed bypass clears any stale receipt and leaves setup incomplete. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_peer_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_peer_config.md new file mode 100644 index 0000000000..058a2dc6f5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_peer_config.md @@ -0,0 +1,21 @@ +# installation_peer_config + +## `node peer-config.mjs` + +``` +The generator pins all frozen-lockfile settings despite user and environment values. +Without the installer override, real pnpm rejects auto-install-peers. +Frozen setup overrides auto-install-peers in user and environment configuration. +Without the installer override, real pnpm rejects peers-suffix-max-length. +Frozen setup overrides peers-suffix-max-length in user and environment configuration. +Without the installer override, real pnpm rejects exclude-links-from-lockfile. +Frozen setup overrides exclude-links-from-lockfile in user and environment configuration. +Without the installer override, real pnpm rejects dedupe-peers. +Frozen setup overrides dedupe-peers in user and environment configuration. +Without the installer override, real pnpm rejects inject-workspace-packages. +Frozen setup overrides inject-workspace-packages in user and environment configuration. +Without the installer override, real pnpm rejects lockfile. +Frozen setup overrides lockfile in user and environment configuration. +All installs fetch the CLI and its peer using the user registry and authentication. +The published lockfile and user configuration remain unchanged. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md index ab15b8c23e..9642c7f7e3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_resolution_retry.md @@ -5,7 +5,7 @@ ``` Before installation: preserve this output. A failed resolution-file write leaves dependencies installed and setup incomplete. -Retry returns the requested Node version without installing dependencies again. +Retry reuses the completed frozen install and returns the requested Node version. Bootstrap stdout contains only shell assignments. After installation. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_upgrade_handoff.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_upgrade_handoff.md new file mode 100644 index 0000000000..8f8729a10f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_upgrade_handoff.md @@ -0,0 +1,10 @@ +# installation_upgrade_handoff + +## `node install.mjs upgrade-handoff` + +``` +Before installation: preserve this output. +Upgrade handoff completes self-setup without another runtime lookup or dependency install. +The first command runs after self-setup completes. +After installation. +``` diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index c782b35d54..06621ebce1 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -238,9 +238,17 @@ async fn run(source: &Path, bundled: bool) -> Result { temporary.persist(binary.as_path()).map_err(|error| error.error)?; } } - let needs_deps = !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file(); - // A standalone setup retry still needs to return its Node selection when dependencies exist. - // In-place upgrades and bundled packages keep their already prepared runtime. + // Old upgrade clients and interrupted installs may leave an incomplete tree. + // Only an explicit lockfile bypass can reuse a completed unlocked install. + let needs_dependency_install = if !skip_deps && !bundled { + let source = AbsolutePathBuf::new(source.to_path_buf()).ok_or(Error::CliBinaryNotFound)?; + vp_setup::lockfile::prepare(&source, &version_dir, version, registry).await? + } else { + false + }; + let needs_deps = needs_dependency_install + || !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file(); + // Standalone retries also return the selected runtime when dependencies already exist. let node_version = if !skip_deps && (deploy || needs_deps) { runtime::resolve().await? } else { None }; if needs_deps { diff --git a/crates/vp_setup/Cargo.toml b/crates/vp_setup/Cargo.toml index 6ad65eb3d0..520960f800 100644 --- a/crates/vp_setup/Cargo.toml +++ b/crates/vp_setup/Cargo.toml @@ -15,6 +15,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } tar = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } @@ -29,7 +30,6 @@ junction = { workspace = true } [dev-dependencies] httpmock = { workspace = true } -tempfile = { workspace = true } vp_shared = { workspace = true, features = ["test-utils"] } [lib] diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index f512ab3e13..49fbc8f04a 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -15,9 +15,10 @@ use flate2::read::GzDecoder; use tar::Archive; use vp_js_runtime::{JsRuntimeType, NodeProvider, download_runtime}; use vp_pm_cli::{PackageManagerType, download_package_manager}; +use vp_shared::EnvConfig; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use crate::error::Error; +use crate::{error::Error, lockfile}; /// Validate that a path from a tarball entry is safe (no path traversal). /// @@ -31,11 +32,12 @@ fn is_safe_tar_path(path: &Path) -> bool { && !path.components().any(|c| matches!(c, std::path::Component::ParentDir)) } -/// Extract the platform-specific package (binary only). +/// Extract the platform-specific binary and its installation lockfile. /// /// From the platform tarball, extracts: /// - The `vp` binary → `{version_dir}/bin/vp` /// - The `vp-shim.exe` trampoline → `{version_dir}/bin/vp-shim.exe` (Windows only) +/// - The release lockfile → `{version_dir}/bin/vp-install-lock.yaml` /// /// `.node` files are no longer extracted here — npm installs them /// via the platform package's optionalDependencies. @@ -48,11 +50,14 @@ pub async fn extract_platform_package( let data = tgz_data.to_vec(); let bin_dir_clone = bin_dir.clone(); + let skip_lockfile = EnvConfig::get().setup_skip_install_lockfile; tokio::task::spawn_blocking(move || { let cursor = Cursor::new(data); let decoder = GzDecoder::new(cursor); let mut archive = Archive::new(decoder); + let mut requires_lockfile = false; + let mut has_lockfile = false; for entry_result in archive.entries()? { let mut entry = entry_result?; @@ -68,6 +73,23 @@ pub async fn extract_platform_package( let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if relative == Path::new("package.json") && entry.header().entry_type().is_file() { + let manifest: serde_json::Value = serde_json::from_reader(&mut entry)?; + requires_lockfile = manifest + .get("vpInstallLockfile") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + } else if !skip_lockfile + && relative == Path::new(lockfile::INSTALL_LOCKFILE) + && entry.header().entry_type().is_file() + { + lockfile::write_atomic( + &bin_dir_clone.join(lockfile::INSTALL_LOCKFILE), + &mut entry, + )?; + has_lockfile = true; + } + if file_name == "vp" || file_name == "vp.exe" || file_name == "vp-shim.exe" { // Binary goes to bin/ let target = bin_dir_clone.join(file_name); @@ -84,6 +106,12 @@ pub async fn extract_platform_package( } } + if requires_lockfile && !has_lockfile && !skip_lockfile { + return Err(Error::Setup( + format!("Platform package is missing {}", lockfile::INSTALL_LOCKFILE).into(), + )); + } + Ok::<(), Error>(()) }) .await @@ -94,7 +122,7 @@ pub async fn extract_platform_package( /// The pnpm version pinned in the wrapper package.json for global installs. /// This ensures consistent install behavior regardless of the user's global pnpm version. -const PINNED_PNPM_VERSION: &str = "10.33.0"; +const PINNED_PNPM_VERSION: &str = "10.34.7"; /// A reused target version must run its own setup again before accepting commands. pub async fn clear_self_setup_marker(version_dir: &AbsolutePath) -> Result<(), Error> { @@ -180,16 +208,17 @@ pub async fn install_production_deps( preferred_node_version: Option<&str>, ) -> Result<(), Error> { tracing::debug!("Running pnpm install in {}", version_dir.as_path().display()); + lockfile::invalidate_install(version_dir).await?; + if EnvConfig::get().setup_skip_install_lockfile { + vp_shared::output::warn( + "VP_SETUP_SKIP_INSTALL_LOCKFILE=1: ignoring the installation lockfile; resolving dependencies again.", + ); + } // Keep the bypass local to this Vite+ installation. write_release_age_overrides(version_dir).await?; - // An ancestor workspace must not capture the install or supply its lockfile. - let mut args = vec!["install", "--ignore-workspace"]; - if let Some(registry_url) = registry { - args.push("--registry"); - args.push(registry_url); - } + let args = dependency_install_args(version_dir, registry).await?; let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( show_progress.then_some("Preparing Node.js and pnpm..."), @@ -213,12 +242,43 @@ pub async fn install_production_deps( )); } + lockfile::record_install(version_dir).await?; if show_progress { vp_shared::output::success("Dependencies installed."); } Ok(()) } +async fn dependency_install_args<'a>( + version_dir: &AbsolutePath, + registry: Option<&'a str>, +) -> Result, Error> { + // An ancestor workspace must not capture the install or supply its lockfile. + let mut args = vec!["install", "--ignore-workspace"]; + if EnvConfig::get().setup_skip_install_lockfile { + // Disable both pnpm's CI default and reading any existing working lockfile. + args.extend(["--no-frozen-lockfile", "--lockfile=false"]); + } else if tokio::fs::try_exists(version_dir.join("bin").join(lockfile::INSTALL_LOCKFILE)) + .await? + { + lockfile::restore(version_dir).await?; + // Keep these flags and the numeric setting in run_pnpm_install in sync with + // packages/cli/generate-install-lockfile.ts. + args.extend([ + "--frozen-lockfile", + "--lockfile=true", + "--config.auto-install-peers=true", + "--config.exclude-links-from-lockfile=false", + "--config.dedupe-peers=false", + "--config.inject-workspace-packages=false", + ]); + } + if let Some(registry_url) = registry { + args.extend(["--registry", registry_url]); + } + Ok(args) +} + async fn prepare_install_runtime( preferred_node_version: Option<&str>, ) -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { @@ -296,6 +356,13 @@ async fn run_pnpm_install( .env("CI", "true") .env("PATH", path); + if args.contains(&"--frozen-lockfile") { + // --config leaves numbers as strings. Environment values get pnpm's numeric parsing. + // Override both spellings so neither an inherited value nor the user npmrc can win. + cmd.env("npm_config_peers_suffix_max_length", "1000") + .env("NPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH", "1000"); + } + if let Some(registry_url) = registry { cmd.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY, registry_url); } @@ -660,6 +727,50 @@ mod tests { assert!(!supports_pnpm_node(&serde_json::json!({"engines":{"node":">=18.12"}}), "invalid")); } + #[tokio::test] + async fn release_archives_supply_the_frozen_dependency_tree() { + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let archive = lockfile::test_archive(true, Some(b"release dependency tree")); + extract_platform_package(&archive, &directory).await.unwrap(); + let args = + dependency_install_args(&directory, Some("https://mirror.example")).await.unwrap(); + assert_eq!( + args, + [ + "install", + "--ignore-workspace", + "--frozen-lockfile", + "--lockfile=true", + "--config.auto-install-peers=true", + "--config.exclude-links-from-lockfile=false", + "--config.dedupe-peers=false", + "--config.inject-workspace-packages=false", + "--registry", + "https://mirror.example" + ] + ); + assert_eq!( + tokio::fs::read(directory.join("pnpm-lock.yaml")).await.unwrap(), + b"release dependency tree" + ); + } + + #[tokio::test] + async fn incomplete_release_archives_fail_but_old_archives_remain_supported() { + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let error = extract_platform_package(&lockfile::test_archive(true, None), &directory) + .await + .unwrap_err(); + assert!(error.to_string().contains("missing vp-install-lock.yaml")); + extract_platform_package(&lockfile::test_archive(false, None), &directory).await.unwrap(); + assert_eq!( + dependency_install_args(&directory, None).await.unwrap(), + ["install", "--ignore-workspace"] + ); + } + #[tokio::test] async fn invalidate_only_the_target_versions_setup() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index a492f784fd..d81316381f 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -18,6 +18,7 @@ pub mod error; pub mod install; pub mod integrity; +pub mod lockfile; pub mod platform; pub mod registry; diff --git a/crates/vp_setup/src/lockfile.rs b/crates/vp_setup/src/lockfile.rs new file mode 100644 index 0000000000..c3aeac47c5 --- /dev/null +++ b/crates/vp_setup/src/lockfile.rs @@ -0,0 +1,402 @@ +//! The release lockfile travels beside the binary, separately from pnpm's working copy. + +use std::io::{Cursor, Read as _}; + +use flate2::read::GzDecoder; +use sha2::{Digest, Sha256}; +use tar::Archive; +use vp_shared::EnvConfig; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::{error::Error, integrity, platform, registry}; + +pub const INSTALL_LOCKFILE: &str = "vp-install-lock.yaml"; +const INSTALL_RECEIPT: &str = "node_modules/.vp-install-complete"; +// This can never match a frozen install's 32-byte SHA-256 digest. +const UNLOCKED_INSTALL_RECEIPT: &[u8] = b"unlocked"; + +/// Publish only complete files, including when an archive read or disk write fails. +pub(crate) fn write_atomic( + target: &AbsolutePath, + mut content: impl std::io::Read, +) -> std::io::Result<()> { + let parent = target.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "File has no parent") + })?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + std::io::copy(&mut content, &mut temporary)?; + temporary.as_file().sync_all()?; + temporary.persist(target).map_err(|error| error.error)?; + Ok(()) +} + +async fn write(target: AbsolutePathBuf, content: Vec) -> Result<(), Error> { + tokio::task::spawn_blocking(move || write_atomic(&target, content.as_slice())).await.map_err( + |error| Error::Setup(format!("Failed to write installation lockfile: {error}").into()), + )??; + Ok(()) +} + +async fn read_optional(path: &AbsolutePath) -> Result>, Error> { + match tokio::fs::read(path).await { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +/// An interrupted install must not reuse a receipt from an earlier attempt. +pub(crate) async fn invalidate_install(version_dir: &AbsolutePath) -> Result<(), Error> { + match tokio::fs::remove_file(version_dir.join(INSTALL_RECEIPT)).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +/// Record a successful frozen or explicitly unlocked install, independently of self-setup. +pub(crate) async fn record_install(version_dir: &AbsolutePath) -> Result<(), Error> { + if EnvConfig::get().setup_skip_install_lockfile { + return write(version_dir.join(INSTALL_RECEIPT), UNLOCKED_INSTALL_RECEIPT.to_vec()).await; + } + let Some(content) = read_optional(&version_dir.join("bin").join(INSTALL_LOCKFILE)).await? + else { + return Ok(()); + }; + write(version_dir.join(INSTALL_RECEIPT), Sha256::digest(content).to_vec()).await +} + +/// Restore the release's lockfile before a frozen install. Returns whether it changed. +pub async fn restore(version_dir: &AbsolutePath) -> Result { + let content = tokio::fs::read(version_dir.join("bin").join(INSTALL_LOCKFILE)).await?; + let target = version_dir.join("pnpm-lock.yaml"); + if read_optional(&target).await?.as_deref() == Some(content.as_slice()) { + return Ok(false); + } + // Invalidate before restoring: a failure after this write must still retry the install. + invalidate_install(version_dir).await?; + write(target, content).await?; + Ok(true) +} + +/// Copy the bootstrap companion, or recover it for a standalone executable. +/// Returns whether this release still needs a dependency installation. +/// Older releases without a companion retain their existing installation behavior. +pub async fn prepare( + source: &AbsolutePath, + version_dir: &AbsolutePath, + version: &str, + registry_override: Option<&str>, +) -> Result { + if EnvConfig::get().setup_skip_install_lockfile { + // Only reuse a completed bypass install while the bypass is explicitly enabled. + // Do not read, restore, or recover the companion, even when it is missing or broken. + let receipt = read_optional(&version_dir.join(INSTALL_RECEIPT)).await?; + return Ok(receipt.as_deref() != Some(UNLOCKED_INSTALL_RECEIPT)); + } + let target = version_dir.join("bin").join(INSTALL_LOCKFILE); + let sibling = source + .parent() + .ok_or_else(|| Error::Setup("Binary has no parent".into()))? + .join(INSTALL_LOCKFILE); + if sibling != target && tokio::fs::try_exists(&sibling).await? { + // Read before writing: different paths may alias the same file through + // a symlinked installation root. Copying it onto itself would truncate it. + let content = tokio::fs::read(sibling).await?; + write(target.clone(), content).await?; + } + if !tokio::fs::try_exists(&target).await? { + // A copied executable, including one extracted by an older vp upgrade, + // may have lost its companion. Only fetch archives that advertise it. + let suffix = platform::detect_platform_suffix()?; + let resolved = + registry::resolve_platform_package(version, &suffix, registry_override).await?; + if !resolved.has_install_lockfile { + return Ok(false); + } + let data = vp_pm_cli::HttpClient::new() + .get_bytes(&resolved.platform_tarball_url) + .await + .map_err(|error| { + Error::Setup(format!("Failed to download installation lockfile: {error}").into()) + })?; + integrity::verify_integrity(&data, &resolved.platform_integrity)?; + let content = + tokio::task::spawn_blocking(move || from_archive(&data)).await.map_err(|error| { + Error::Setup(format!("Failed to read installation lockfile: {error}").into()) + })??; + let content = content.ok_or_else(|| { + Error::Setup(format!("Platform package is missing {INSTALL_LOCKFILE}").into()) + })?; + write(target.clone(), content).await?; + } + restore(version_dir).await?; + let Some(receipt) = read_optional(&version_dir.join(INSTALL_RECEIPT)).await? else { + return Ok(true); + }; + let content = tokio::fs::read(target).await?; + Ok(receipt != Sha256::digest(content).as_slice()) +} + +fn from_archive(data: &[u8]) -> Result>, Error> { + let mut archive = Archive::new(GzDecoder::new(Cursor::new(data))); + for entry in archive.entries()? { + let mut entry = entry?; + if entry.path()?.as_ref() == std::path::Path::new("package").join(INSTALL_LOCKFILE) + && entry.header().entry_type().is_file() + { + let mut content = Vec::new(); + entry.read_to_end(&mut content)?; + return Ok(Some(content)); + } + } + Ok(None) +} + +#[cfg(test)] +pub(crate) fn test_archive(requires_lockfile: bool, content: Option<&[u8]>) -> Vec { + use flate2::{Compression, write::GzEncoder}; + + let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); + let manifest = serde_json::json!({ "vpInstallLockfile": requires_lockfile }).to_string(); + let mut files = vec![("package/package.json".to_owned(), manifest.as_bytes())]; + if let Some(content) = content { + files.push((format!("package/{INSTALL_LOCKFILE}"), content)); + } + for (path, data) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, path, data).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap() +} + +#[cfg(test)] +mod tests { + use httpmock::{Method::GET, MockServer}; + use sha2::{Digest, Sha512}; + use vt_path::AbsolutePathBuf; + + use super::*; + + #[test] + fn failed_writes_preserve_the_previous_file_and_allow_retry() { + struct FailedRead; + impl std::io::Read for FailedRead { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("interrupted archive")) + } + } + + for previous in [None, Some(b"previous lock".as_slice())] { + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let target = directory.join(INSTALL_LOCKFILE); + if let Some(content) = previous { + std::fs::write(&target, content).unwrap(); + } + let partial = Cursor::new(b"partial lock").chain(FailedRead); + assert!(write_atomic(&target, partial).is_err()); + assert_eq!(std::fs::read(&target).ok().as_deref(), previous); + // The incomplete temporary file is discarded, never published as the companion. + assert_eq!( + std::fs::read_dir(&directory).unwrap().count(), + usize::from(previous.is_some()) + ); + write_atomic(&target, b"complete lock".as_slice()).unwrap(); + assert_eq!(std::fs::read(target).unwrap(), b"complete lock"); + } + } + + #[tokio::test] + async fn only_completed_installs_of_the_current_lockfile_are_reused() { + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let source = directory.join("bin/vp"); + tokio::fs::create_dir(directory.join("bin")).await.unwrap(); + tokio::fs::create_dir(directory.join("node_modules")).await.unwrap(); + tokio::fs::write(directory.join("bin").join(INSTALL_LOCKFILE), "release lock") + .await + .unwrap(); + assert!(prepare(&source, &directory, "1.4.0", None).await.unwrap()); + + record_install(&directory).await.unwrap(); + tokio::fs::write(directory.join("bin").join(crate::SELF_SETUP_MARKER), "").await.unwrap(); + crate::install::clear_self_setup_marker(&directory).await.unwrap(); + assert!(!prepare(&source, &directory, "1.4.0", None).await.unwrap()); + + // A new attempt that fails must not retain an earlier success receipt. + invalidate_install(&directory).await.unwrap(); + assert!(prepare(&source, &directory, "1.4.0", None).await.unwrap()); + + record_install(&directory).await.unwrap(); + tokio::fs::write(directory.join("pnpm-lock.yaml"), "modified lock").await.unwrap(); + assert!(prepare(&source, &directory, "1.4.0", None).await.unwrap()); + // Restoring the lockfile alone must not make a subsequent retry skip pnpm. + assert!(prepare(&source, &directory, "1.4.0", None).await.unwrap()); + + record_install(&directory).await.unwrap(); + for path in ["bin/vp-install-lock.yaml", "pnpm-lock.yaml"] { + tokio::fs::write(directory.join(path), "different release lock").await.unwrap(); + } + assert!(prepare(&source, &directory, "1.4.0", None).await.unwrap()); + record_install(&directory).await.unwrap(); + assert!(!prepare(&source, &directory, "1.4.0", None).await.unwrap()); + + tokio::fs::remove_dir_all(directory.join("node_modules")).await.unwrap(); + assert!(prepare(&source, &directory, "1.4.0", None).await.unwrap()); + } + + #[tokio::test] + async fn bootstrap_copies_the_release_lock_and_replaces_an_old_clients_resolution() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let source = root.join("download/vp"); + let version_dir = root.join("installed"); + tokio::fs::create_dir_all(root.join("download")).await.unwrap(); + tokio::fs::create_dir_all(version_dir.join("bin")).await.unwrap(); + tokio::fs::write(root.join("download").join(INSTALL_LOCKFILE), "release lock") + .await + .unwrap(); + tokio::fs::write(version_dir.join("pnpm-lock.yaml"), "freshly resolved tree") + .await + .unwrap(); + + assert!(prepare(&source, &version_dir, "1.4.0", Some("http://127.0.0.1:1")).await.unwrap()); + assert_eq!( + tokio::fs::read_to_string(version_dir.join("pnpm-lock.yaml")).await.unwrap(), + "release lock" + ); + // A retry still requires a frozen install even when a failed attempt + // already restored the working lockfile. No registry request is needed. + assert!( + prepare(&version_dir.join("bin/vp"), &version_dir, "1.4.0", Some("http://127.0.0.1:1")) + .await + .unwrap() + ); + tokio::fs::write(version_dir.join("pnpm-lock.yaml"), "modified working copy") + .await + .unwrap(); + assert!(restore(&version_dir).await.unwrap()); + #[cfg(unix)] + { + let alias = root.join("alias"); + std::os::unix::fs::symlink(&version_dir, &alias).unwrap(); + assert!(prepare(&alias.join("bin/vp"), &version_dir, "1.4.0", None).await.unwrap()); + assert_eq!( + tokio::fs::read_to_string(version_dir.join("bin").join(INSTALL_LOCKFILE)) + .await + .unwrap(), + "release lock" + ); + } + } + + #[tokio::test] + async fn standalone_and_old_upgrade_clients_recover_the_verified_lockfile() { + for version in ["1.4.0", "0.0.0-commit.0123456789abcdef0123456789abcdef01234567"] { + let server = MockServer::start(); + let data = test_archive(true, Some(b"published lock")); + let suffix = platform::detect_platform_suffix().unwrap(); + let metadata = server.mock(|when, then| { + when.method(GET).path(format!("/@voidzero-dev/vite-plus-cli-{suffix}/{version}")); + then.status(200).json_body(serde_json::json!({ + "version": version, "vpInstallLockfile": true, + "dist": { + "tarball": format!("{}/platform.tgz", server.base_url()), + "integrity": format!("sha512-{}", base64_simd::STANDARD.encode_to_string(Sha512::digest(&data))), + "attestations": { "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } } + } + })); + }); + let tarball = server.mock(|when, then| { + when.method(GET).path("/platform.tgz"); + then.status(200).body(data); + }); + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + tokio::fs::create_dir(directory.join("bin")).await.unwrap(); + assert!( + prepare(&directory.join("vp"), &directory, version, Some(&server.base_url())) + .await + .unwrap() + ); + assert_eq!( + tokio::fs::read(directory.join("pnpm-lock.yaml")).await.unwrap(), + b"published lock" + ); + metadata.assert(); + tarball.assert(); + } + } + + #[tokio::test] + async fn missing_or_modified_published_lockfiles_fail_without_falling_back() { + for corrupt in [false, true] { + let server = MockServer::start(); + let data = test_archive(true, None); + let suffix = platform::detect_platform_suffix().unwrap(); + server.mock(|when, then| { + when.method(GET).path(format!("/@voidzero-dev/vite-plus-cli-{suffix}/1.4.0")); + then.status(200).json_body(serde_json::json!({ + "version": "1.4.0", "vpInstallLockfile": true, + "dist": { + "tarball": format!("{}/platform.tgz", server.base_url()), + "integrity": format!("sha512-{}", base64_simd::STANDARD.encode_to_string(Sha512::digest(if corrupt { b"different".as_slice() } else { &data }))), + "attestations": { "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } } + } + })); + }); + server.mock(|when, then| { + when.method(GET).path("/platform.tgz"); + then.status(200).body(data); + }); + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + tokio::fs::create_dir(directory.join("bin")).await.unwrap(); + let error = + prepare(&directory.join("vp"), &directory, "1.4.0", Some(&server.base_url())) + .await + .unwrap_err(); + assert!(error.to_string().contains(if corrupt { + "Integrity mismatch" + } else { + "missing vp-install-lock.yaml" + })); + assert!(!directory.join("pnpm-lock.yaml").as_path().exists()); + } + } + + #[tokio::test] + async fn legacy_releases_and_previews_without_companions_keep_existing_behavior() { + for version in ["1.3.0", "0.0.0-commit.0123456789abcdef0123456789abcdef01234567"] { + let server = MockServer::start(); + let suffix = platform::detect_platform_suffix().unwrap(); + let metadata = server.mock(|when, then| { + when.method(GET).path(format!("/@voidzero-dev/vite-plus-cli-{suffix}/{version}")); + then.status(200).json_body(serde_json::json!({ + "version": version, + "dist": { + "tarball": format!("{}/unused.tgz", server.base_url()), "integrity": "sha512-old", + "attestations": { "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } } + } + })); + }); + let tarball = server.mock(|when, then| { + when.method(GET).path("/unused.tgz"); + then.status(500); + }); + let temp = tempfile::tempdir().unwrap(); + let directory = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + assert!( + !prepare(&directory.join("vp"), &directory, version, Some(&server.base_url())) + .await + .unwrap() + ); + metadata.assert(); + tarball.assert_calls(0); + } + } +} diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index 5c6d5e8a32..62d8697702 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -13,6 +13,8 @@ use crate::{error::Error, is_commit_preview_version}; pub struct PackageVersionMetadata { pub version: String, pub dist: DistInfo, + #[serde(default, rename = "vpInstallLockfile")] + pub has_install_lockfile: bool, } /// Distribution info from npm registry. @@ -42,6 +44,7 @@ pub struct ResolvedVersion { pub version: String, pub platform_tarball_url: String, pub platform_integrity: String, + pub has_install_lockfile: bool, } const MAIN_PACKAGE_NAME: &str = "vite-plus"; @@ -137,6 +140,7 @@ pub async fn resolve_platform_package( version: version.to_owned(), platform_tarball_url: cli_meta.dist.tarball, platform_integrity: cli_meta.dist.integrity, + has_install_lockfile: cli_meta.has_install_lockfile, }) } diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index e7d801a7f3..1182ac79d3 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -162,6 +162,9 @@ pub struct EnvConfig { /// Optional file for returning the resolved project Node version. pub setup_node_version_file: Option, + /// Emergency bypass of the global CLI installation lockfile, enabled only by `1`. + pub setup_skip_install_lockfile: bool, + /// Override package manager and version. /// /// Env: `VP_PACKAGE_MANAGER` @@ -225,6 +228,9 @@ impl EnvConfig { node_version: std::env::var(env_vars::VP_NODE_VERSION).ok(), setup_node_version: std::env::var(env_vars::VP_SETUP_NODE_VERSION).ok(), setup_node_version_file: std::env::var(env_vars::VP_SETUP_NODE_VERSION_FILE).ok(), + setup_skip_install_lockfile: std::env::var(env_vars::VP_SETUP_SKIP_INSTALL_LOCKFILE) + .as_deref() + == Ok("1"), package_manager: std::env::var(env_vars::VP_PACKAGE_MANAGER).ok(), npm_version: std::env::var(env_vars::VP_NPM_VERSION).ok(), pnpm_version: std::env::var(env_vars::VP_PNPM_VERSION).ok(), @@ -412,6 +418,16 @@ impl EnvConfig { mod tests { use super::*; + #[test] + fn installation_lockfile_bypass_requires_explicit_opt_in() { + for value in [None, Some(""), Some("0"), Some("true"), Some("1")] { + EnvConfig::with_vars([(env_vars::VP_SETUP_SKIP_INSTALL_LOCKFILE, value)], |config| { + assert_eq!(config.setup_skip_install_lockfile, value == Some("1")); + assert!(!config.dir_envs.contains_key(env_vars::VP_SETUP_SKIP_INSTALL_LOCKFILE)); + }); + } + } + /// `VP_HOME` pins every category to the single-root mapping. #[test] fn with_vars_vp_home_pins_single_root() { diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 7b9a5bfe6d..9a20e34fba 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -90,6 +90,9 @@ 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"; +/// Resolve global CLI dependencies without the installation lockfile when set to `1`. +pub const VP_SETUP_SKIP_INSTALL_LOCKFILE: &str = "VP_SETUP_SKIP_INSTALL_LOCKFILE"; + /// Override package manager and version for vp commands (for example, `pnpm@10.18.0`). /// Direct package-manager shims use their own version overrides instead. pub const VP_PACKAGE_MANAGER: &str = "VP_PACKAGE_MANAGER"; diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index e4e9182036..820fc36c14 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -123,6 +123,31 @@ These variables control the installer scripts and the standalone Windows install `setup-vp` manages this file automatically. Use its Node.js inputs to configure CI jobs. +#### `VP_SETUP_SKIP_INSTALL_LOCKFILE` + +Set this variable to `1` to recover from a lockfile-related error during global CLI installation or `vp upgrade`. All other values, including unset or empty, keep the published installation lockfile enabled. + +The installer prints a warning and resolves dependencies without reading or recovering the installation lockfile. The requested CLI version stays pinned, but its dependency versions can differ from the release lockfile. Registry, authentication, and proxy settings remain in effect. This option does not affect project installations such as `vp install`. + +```bash +# Retry installation without the bundled lockfile. +curl -fsSL https://vite.plus | VP_SETUP_SKIP_INSTALL_LOCKFILE=1 bash + +# Or retry an upgrade, including the currently installed version. +VP_SETUP_SKIP_INSTALL_LOCKFILE=1 vp upgrade --force +``` + +```powershell +$env:VP_SETUP_SKIP_INSTALL_LOCKFILE = "1" +try { + irm https://vite.plus/ps1 | iex +} finally { + Remove-Item Env:VP_SETUP_SKIP_INSTALL_LOCKFILE +} +``` + +Keep this override temporary. Later installations and upgrades use the published lockfile again when the variable is unset. To reinstall an affected version with its lockfile, unset the variable and run `vp upgrade --force`. + #### `VP_HOME` - **Purpose**: Optional pin for the single-root layout. Set it to an absolute path. Vite+ then puts bin, data, cache, config, and state under that directory. The installed CLI reads the same variable. See [Environment](/guide/env). diff --git a/packages/cli/BUNDLING.md b/packages/cli/BUNDLING.md index 514b3f2e6f..872fc3875e 100644 --- a/packages/cli/BUNDLING.md +++ b/packages/cli/BUNDLING.md @@ -346,6 +346,72 @@ These are automatically installed via `optionalDependencies` based on the user's See `publish-native-addons.ts` for the publishing pipeline. +### Global installation lockfile + +The release workflow publishes native bindings, prompts, and core first. It then +publishes `vite-plus` with the `staging` tag and waits until that exact version is +installable. `generate-install-lockfile.ts` runs pnpm `10.34.7`, the same version +used by the installer, in an isolated `vp-global` wrapper directory. The wrapper +pins `vite-plus` to the release version. `pnpm install --lockfile-only` generates +a standard `pnpm-lock.yaml` from the published registry metadata. The script +copies its contents unchanged to `vp-install-lock.yaml` for packaging. The +lockfile includes package integrity and optional dependencies for all supported +platforms. + +The workflow saves the lockfile in the `installation-lockfile` artifact before +publishing any global CLI package. Release retries restore that lockfile. If the +artifact is unavailable, the generator can recover the same lockfile from an +already published global platform package. The local CLI is already published, +so retries do not need to preserve or repack a local CLI archive. + +Build artifact uploads replace earlier build artifacts when a failed job runs +again, but retries preserve the saved installation lockfile. +Package publication skips versions that npm already accepted, including during +its scanning window. A retry resumes uploads to a draft GitHub release and +leaves an already published GitHub release and its assets unchanged. + +Each global CLI platform package and standalone release archive carries the +lockfile beside `vp`. After the global packages propagate, the workflow runs +`npm dist-tag add "vite-plus@${VERSION}" latest`. The installer continues to +select the previous release until this step. The `staging` tag remains available +for inspecting the most recently staged version. + +Tag promotion uses npm `11.21.0` with OIDC. The `vite-plus` trusted publisher for +`release.yml` must enable **Allow npm dist-tag**, in addition to its existing +publish permission. See [npm's trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/#managing-dist-tags-with-trusted-publishing). +This is a distribution tag named `staging`, not the separate `npm stage publish` +approval flow. + +Bootstrap scripts hand the binary and its companion to self-setup, which uses +`--frozen-lockfile`. Upgrades use the same lockfile. A standalone binary or an +older upgrade client can recover it from the verified platform package. Older +packages without a companion retain their existing installation behavior. The +release-age override remains `0` so a new release can be installed immediately. + +Preview builds pack the local packages and global CLI platform packages into +separate artifacts. The trusted publishing workflow first publishes the local +CLI and its dependencies under their exact commit version. It omits the PR URL +from this phase, so the bridge exposes the commit version without moving the +`pr-` tag. A job without publishing credentials then runs the same pnpm +lockfile generator against the registry bridge and bundles the result into the +global CLI packages. The bridge supplies the published archive integrity and +content-addressed URLs directly; the generator does not predict them. + +The workflow saves the prepared global packages before publishing them and +reuses that artifact on retries. If a new run has no saved artifact, it recovers +the lockfile from an already published global platform package before attempting +a new pnpm resolution. A final publishing job validates and rebuilds +those global packages, then updates the PR tag. Both publishing jobs re-check PR +authorization and do not run dependency installation or preview code. The fork +approval gate precedes both jobs. Global CLI packages stay outside the locked +local CLI dependency graph, so adding the companion cannot change a hash in the +lockfile. + +Use **Re-run failed jobs** to resume a failed release or preview publication. +The release reuses the successful version check against `vite-plus@latest`. +Preview retries still require the same PR head, an open PR, and the +`preview-build` label. If the build artifacts expire, start a new preview build. + --- ## Core Package Export Sync Details diff --git a/packages/cli/generate-install-lockfile.ts b/packages/cli/generate-install-lockfile.ts new file mode 100644 index 0000000000..59c3b211c0 --- /dev/null +++ b/packages/cli/generate-install-lockfile.ts @@ -0,0 +1,249 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { gunzipSync } from 'node:zlib'; + +import { parseTriple } from '@napi-rs/cli'; +import { parseTar } from 'nanotar'; +import { parse } from 'yaml'; + +import pkg from './package.json' with { type: 'json' }; + +export const INSTALL_LOCKFILE = 'vp-install-lock.yaml'; +// Keep in sync with the global installer in crates/vp_setup/src/install.rs. +export const INSTALL_PNPM_VERSION = '10.34.7'; +export const PREVIEW_REGISTRY = 'https://registry-bridge.viteplus.dev'; +const exec = promisify(execFile); + +interface InstallManifest { + name: string; + version: string; + private: boolean; + packageManager: string; + dependencies: { 'vite-plus': string }; +} + +interface PackageResolution { + integrity?: string; + tarball?: string; +} + +export function installManifest(version: string): InstallManifest { + return { + name: 'vp-global', + version, + private: true, + packageManager: `pnpm@${INSTALL_PNPM_VERSION}`, + dependencies: { 'vite-plus': version }, + }; +} + +export function isCommitPreviewVersion(version: string): boolean { + return /^0\.0\.0-commit\.[a-f0-9]{40}$/.test(version); +} + +function hasValidTarball( + packageKey: string, + resolution: PackageResolution | undefined, + version: string, +): boolean { + const tarball = resolution?.tarball; + if (!tarball) { + return true; + } + // Releases must remain portable across registry mirrors. + if (!isCommitPreviewVersion(version)) { + return false; + } + const name = packageKey.slice(0, packageKey.lastIndexOf('@')); + if (packageKey !== `${name}@${version}`) { + // Dependencies proxied from npm retain their original tarball URLs. + return !!resolution.integrity && tarball.startsWith('https://registry.npmjs.org/'); + } + const prefix = `${PREVIEW_REGISTRY}/tarballs/${name}/${version}/`; + return tarball.startsWith(prefix) && /^[a-f0-9]{40}\.tgz$/.test(tarball.slice(prefix.length)); +} + +export function validateInstallLockfile(content: string, version: string): void { + const lock = parse(content) as { + lockfileVersion?: string; + importers?: Record< + string, + { dependencies?: Record } + >; + packages?: Record; + } | null; + const cli = lock?.importers?.['.']?.dependencies?.['vite-plus']; + const integrity = lock?.packages?.[`vite-plus@${version}`]?.resolution?.integrity; + if ( + String(lock?.lockfileVersion) !== '9.0' || + cli?.specifier !== version || + (cli.version !== version && !cli.version?.startsWith(`${version}(`)) || + !integrity || + !Object.entries(lock?.packages ?? {}).every(([key, entry]) => + hasValidTarball(key, entry.resolution, version), + ) + ) { + throw new Error(`Invalid global installation lockfile for vite-plus@${version}`); + } +} + +/** Reuse the first published platform's lockfile after a partial release failure. */ +export async function publishedInstallLockfile( + version: string, + registry: string, + platformPackages: string[], + fetchImpl: typeof fetch = fetch, +): Promise { + for (const name of platformPackages) { + const response = await fetchImpl(`${registry.replace(/\/$/, '')}/${name}/${version}`, { + signal: AbortSignal.timeout(30_000), + }); + if (response.status === 404) { + continue; + } + if (!response.ok) { + throw new Error(`Could not check ${name}@${version}: HTTP ${response.status}`); + } + const metadata = (await response.json()) as { + vpInstallLockfile?: boolean; + dist: { tarball: string; integrity: string }; + }; + if (!metadata.vpInstallLockfile) { + throw new Error(`${name}@${version} was published without an installation lockfile`); + } + const archive = await fetchImpl(metadata.dist.tarball, { signal: AbortSignal.timeout(60_000) }); + if (!archive.ok) { + throw new Error(`Could not download ${name}@${version}: HTTP ${archive.status}`); + } + const bytes = Buffer.from(await archive.arrayBuffer()); + if ( + `sha512-${createHash('sha512').update(bytes).digest('base64')}` !== metadata.dist.integrity + ) { + throw new Error(`Integrity mismatch for ${name}@${version}`); + } + const files = parseTar(new Uint8Array(gunzipSync(bytes))); + const lockfile = files.find((file) => file.name === `package/${INSTALL_LOCKFILE}`); + if (!lockfile?.data) { + throw new Error(`${name}@${version} is missing ${INSTALL_LOCKFILE}`); + } + const content = new TextDecoder().decode(lockfile.data); + validateInstallLockfile(content, version); + return content; + } + return undefined; +} + +async function runPnpm(directory: string, args: string[], env: NodeJS.ProcessEnv): Promise { + await exec( + 'npm', + ['exec', '--yes', `--package=pnpm@${INSTALL_PNPM_VERSION}`, '--', 'pnpm', ...args], + { + cwd: directory, + env: { ...process.env, ...env, CI: 'true' }, + maxBuffer: 10 * 1024 * 1024, + }, + ); +} + +export async function generateInstallLockfile( + version: string, + registry: string, + runPnpmCommand = runPnpm, +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'vp-install-lock-')); + try { + await writeFile(join(directory, 'package.json'), JSON.stringify(installManifest(version))); + await writeFile(join(directory, '.npmrc'), 'minimum-release-age=0\n'); + // Keep all five lockfile.settings values in sync with crates/vp_setup/src/install.rs. + await runPnpmCommand( + directory, + [ + 'install', + '--ignore-workspace', + '--config.auto-install-peers=true', + '--config.exclude-links-from-lockfile=false', + '--config.dedupe-peers=false', + '--config.inject-workspace-packages=false', + '--lockfile-only', + '--ignore-scripts', + '--no-frozen-lockfile', + '--registry', + registry, + ], + { + // --config leaves numbers as strings. Environment values get pnpm's numeric parsing. + npm_config_peers_suffix_max_length: '1000', + NPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH: '1000', + }, + ); + const content = await readFile(join(directory, 'pnpm-lock.yaml'), 'utf8'); + validateInstallLockfile(content, version); + return content; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +/** Add the same lockfile to the standalone archives before computing checksums. */ +export async function addInstallLockfileToArchives(directory: string): Promise { + const lockfile = join(directory, INSTALL_LOCKFILE); + for (const name of await readdir(directory)) { + if (!name.startsWith('vp-')) { + continue; + } + const archive = resolve(directory, name); + if (name.endsWith('.zip')) { + await exec('zip', ['-j', archive, lockfile]); + } else if (name.endsWith('.tar.gz')) { + const temporary = await mkdtemp(join(tmpdir(), 'vp-release-archive-')); + try { + await exec('tar', ['-xzf', archive, '-C', temporary]); + await copyFile(lockfile, join(temporary, INSTALL_LOCKFILE)); + await exec('tar', ['-czf', archive, '-C', temporary, ...(await readdir(temporary))]); + } finally { + await rm(temporary, { recursive: true, force: true }); + } + } + } +} + +async function main(): Promise { + const version = process.env.VERSION; + const directory = resolve('binary-release'); + if (!version || version !== pkg.version) { + throw new Error('VERSION must match packages/cli/package.json'); + } + const registry = process.env.PUBLISH_REGISTRY ?? 'https://registry.npmjs.org'; + const platformPackages = pkg.napi.targets.map( + (target) => `@voidzero-dev/vite-plus-cli-${parseTriple(target).platformArchABI}`, + ); + let content: string | undefined; + try { + content = await readFile(join(directory, INSTALL_LOCKFILE), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + content ??= + (await publishedInstallLockfile(version, registry, platformPackages)) ?? + (await generateInstallLockfile(version, registry)); + validateInstallLockfile(content, version); + await writeFile(join(directory, INSTALL_LOCKFILE), content); + await addInstallLockfileToArchives(directory); + // eslint-disable-next-line no-console + console.log(`Prepared ${basename(directory)}/${INSTALL_LOCKFILE} for vite-plus@${version}`); +} + +const invokedPath = process.argv[1]; +if (invokedPath && pathToFileURL(resolve(invokedPath)).href === import.meta.url) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/cli/generate-preview-install-lockfile.ts b/packages/cli/generate-preview-install-lockfile.ts new file mode 100644 index 0000000000..4c0f08fdff --- /dev/null +++ b/packages/cli/generate-preview-install-lockfile.ts @@ -0,0 +1,95 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { createTarGzip, parseTarGzip } from 'nanotar'; +import { parse } from 'yaml'; + +import { + generateInstallLockfile, + INSTALL_LOCKFILE, + isCommitPreviewVersion, + PREVIEW_REGISTRY, + publishedInstallLockfile, + validateInstallLockfile, +} from './generate-install-lockfile.ts'; + +const platformPrefix = '@voidzero-dev/vite-plus-cli-'; +const decoder = new TextDecoder(); + +/** Resolve the published local CLI with pnpm, then bundle its lockfile with each global binary. */ +export async function preparePreviewPackages( + directory: string, + version: string, + resolveLockfile = generateInstallLockfile, + recoverLockfile = publishedInstallLockfile, +): Promise { + if (!isCommitPreviewVersion(version)) { + throw new Error('Expected a full commit preview version'); + } + const archives = []; + const names = new Set(); + for (const file of (await readdir(directory)).filter((name) => /^pkg-\d+\.tgz$/.test(name))) { + const entries = await parseTarGzip(new Uint8Array(await readFile(join(directory, file)))); + const manifest = entries.find((entry) => entry.name === 'package/package.json'); + if (!manifest?.data) { + throw new Error(`${file} is missing package/package.json`); + } + const packageJson = JSON.parse(decoder.decode(manifest.data)); + const name = packageJson.name as string; + if (!name?.startsWith(platformPrefix) || names.has(name)) { + throw new Error(`Expected unique global CLI platform packages, found ${name}`); + } + names.add(name); + archives.push({ file, entries, manifest, packageJson }); + } + if (archives.length === 0) { + throw new Error('Preview batch is missing CLI platform packages'); + } + + // A new registration run can follow a partial publication of this commit. + // Reuse its lockfile even when this run has no prepared-package artifact. + const content = + (await recoverLockfile(version, PREVIEW_REGISTRY, [...names])) ?? + (await resolveLockfile(version, PREVIEW_REGISTRY)); + validateInstallLockfile(content, version); + const lock = parse(content) as { packages: Record }; + if (Object.keys(lock.packages).some((name) => name.startsWith(platformPrefix))) { + throw new Error(`Installation lockfile must not depend on ${platformPrefix} packages`); + } + + for (const { file, entries, manifest, packageJson } of archives) { + manifest.data = new TextEncoder().encode( + JSON.stringify({ + ...packageJson, + vpInstallLockfile: true, + files: [...new Set([...((packageJson.files as string[]) ?? []), INSTALL_LOCKFILE])], + }), + ); + await writeFile( + join(directory, file), + await createTarGzip([ + ...entries.filter((entry) => entry.name !== `package/${INSTALL_LOCKFILE}`), + { name: `package/${INSTALL_LOCKFILE}`, data: content }, + ]), + ); + } + // The bridge validates and normalizes these global packages during upload. + // Their bytes are outside the locked local CLI dependency graph. + return content; +} + +async function main(): Promise { + const directory = process.argv[2]; + if (!directory || !process.env.VERSION) { + throw new Error('Expected global CLI artifact directory and VERSION'); + } + await preparePreviewPackages(resolve(directory), process.env.VERSION); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/cli/install-legacy.ps1 b/packages/cli/install-legacy.ps1 index b3385af181..991d8deefb 100644 --- a/packages/cli/install-legacy.ps1 +++ b/packages/cli/install-legacy.ps1 @@ -840,7 +840,7 @@ function Main { name = "vp-global" version = $ViteVersion private = $true - packageManager = "pnpm@10.33.0" + packageManager = "pnpm@10.34.7" dependencies = @{ "vite-plus" = $vitePlusSpec } diff --git a/packages/cli/install-legacy.sh b/packages/cli/install-legacy.sh index d6b316c4f2..63d1dd6d97 100644 --- a/packages/cli/install-legacy.sh +++ b/packages/cli/install-legacy.sh @@ -1050,7 +1050,7 @@ main() { "name": "vp-global", "version": "$VP_VERSION", "private": true, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@10.34.7", "dependencies": { "vite-plus": "$vite_plus_spec" } diff --git a/packages/cli/publish-native-addons.ts b/packages/cli/publish-native-addons.ts index e42447e82d..6421e77f54 100644 --- a/packages/cli/publish-native-addons.ts +++ b/packages/cli/publish-native-addons.ts @@ -1,4 +1,12 @@ -import { copyFileSync, existsSync, chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { + copyFileSync, + existsSync, + chmodSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { readdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,6 +18,7 @@ import { type NpmPackageVersion, waitForNpmPackagesFromEnv, } from '../../.github/scripts/wait-for-npm-packages.ts'; +import { INSTALL_LOCKFILE, validateInstallLockfile } from './generate-install-lockfile.ts'; import pkg from './package.json' with { type: 'json' }; import { editJsonFile, readJsonFile } from './src/utils/json.ts'; @@ -26,6 +35,12 @@ if (mode !== 'npm' && mode !== 'pkg-pr-new') { process.exit(1); } const skipNpmPublish = mode === 'pkg-pr-new'; +const phaseIdx = args.indexOf('--phase'); +const phase = phaseIdx >= 0 ? args[phaseIdx + 1] : 'all'; +if (phase !== 'native' && phase !== 'cli' && phase !== 'all') { + throw new Error('--phase must be native, cli, or all'); +} +const lockfile = join(repoRoot, 'binary-release', INSTALL_LOCKFILE); const VERSION = process.env.VERSION; if (!VERSION) { @@ -50,44 +65,46 @@ if (existsSync(rustCliArtifactsDir)) { } } -// Create npm directories for NAPI bindings -await cli.createNpmDirs({ - cwd: currentDir, - packageJsonPath: './package.json', -}); - -// Copy NAPI artifacts -await cli.artifacts({ - cwd: currentDir, - packageJsonPath: './package.json', -}); - -// Pre-publish (Update package.json and copy addons into per platform packages) -await cli.prePublish({ - cwd: currentDir, - packageJsonPath: './package.json', - tagStyle: 'npm', - ghRelease: false, - skipOptionalPublish: true, -}); - const npmDir = join(currentDir, 'npm'); -const platformDirs = await readdir(npmDir); - -// The native binding's true ABI floor is Node 20, well below the product -// support policy copied into `engines.node` (e.g. `^20.19.0 || ^22.18.0 || -// >=24.11.0`). Declaring that policy on the platform packages makes engine-strict -// package managers (pnpm) skip the optional native dependency whenever a -// consumer's declared Node floor lands in one of the policy's gaps (20.0-20.18, -// 22.0-22.17, 24.0-24.10), surfacing as "Cannot find native binding". Rewrite each -// platform package to its real ABI floor of `>=20.0.0` so the native dep is never -// skipped. `packages/cli/package.json` and `packages/core/package.json` keep the -// product policy unchanged. -for (const dir of platformDirs) { - editJsonFile(join(npmDir, dir, 'package.json'), (pkgJson) => ({ - ...pkgJson, - engines: { ...(pkgJson.engines as Record), node: '>=20.0.0' }, - })); +if (phase !== 'cli') { + // Create npm directories for NAPI bindings + await cli.createNpmDirs({ + cwd: currentDir, + packageJsonPath: './package.json', + }); + + // Copy NAPI artifacts + await cli.artifacts({ + cwd: currentDir, + packageJsonPath: './package.json', + }); + + // Pre-publish (Update package.json and copy addons into per platform packages) + await cli.prePublish({ + cwd: currentDir, + packageJsonPath: './package.json', + tagStyle: 'npm', + ghRelease: false, + skipOptionalPublish: true, + }); + + const platformDirs = await readdir(npmDir); + + // The native binding's true ABI floor is Node 20, well below the product + // support policy copied into `engines.node` (e.g. `^20.19.0 || ^22.18.0 || + // >=24.11.0`). Declaring that policy on the platform packages makes engine-strict + // package managers (pnpm) skip the optional native dependency whenever a + // consumer's declared Node floor lands in one of the policy's gaps (20.0-20.18, + // 22.0-22.17, 24.0-24.10), surfacing as "Cannot find native binding". Rewrite each + // platform package to its real ABI floor of `>=20.0.0` so the native dep is never + // skipped. `packages/cli/package.json` and `packages/core/package.json` keep the + // product policy unchanged. + for (const dir of platformDirs) { + editJsonFile(join(npmDir, dir, 'package.json'), (pkgJson) => ({ + ...pkgJson, + engines: { ...(pkgJson.engines as Record), node: '>=20.0.0' }, + })); + } } // Fresh read: napi-rs prePublish rewrote this package.json on disk, so the @@ -126,25 +143,38 @@ editJsonFile(join(repoRoot, 'packages', 'core', 'package.json'), (corePkgJson) = ...nativePlatformPins, }, })); -const platformPackages = Object.keys(nativePlatformPins).map((name) => ({ - name, - version: cliVersion, -})); +const platformPackages: NpmPackageVersion[] = []; // Publish each NAPI platform package (without vp binary) const npmTag = process.env.NPM_TAG || 'latest'; const publishArgs = ['publish', '--tag', npmTag, '--access', 'public']; -if (!skipNpmPublish) { - for (const file of platformDirs) { +if (!skipNpmPublish && phase !== 'cli') { + for (const file of await readdir(npmDir)) { const platformDir = join(npmDir, file); const platformPackage = readJsonFile(join(platformDir, 'package.json')) as NpmPackageVersion; await publishNpmPackageFromEnv(platformPackage, 'npm', publishArgs, platformDir); + platformPackages.push(platformPackage); } } // Create and publish separate @voidzero-dev/vite-plus-cli-{platform} packages const cliNpmDir = join(currentDir, 'cli-npm'); -for (const napiTarget of pkg.napi.targets) { +if (phase !== 'native') { + for (const napiTarget of pkg.napi.targets) { + await prepareAndPublishCliPackage(napiTarget); + } +} + +// npm can accept uploads before scanning makes them installable. Wait for the +// platform packages before publishing core and the CLI, which pin their versions. +if (!skipNpmPublish) { + await waitForNpmPackagesFromEnv(platformPackages); + + // Preview releases still need the prepared directories. + rmSync(cliNpmDir, { recursive: true, force: true }); +} + +async function prepareAndPublishCliPackage(napiTarget: string): Promise { const { platform, arch, abi, platformArchABI } = parseTriple(napiTarget); const isWindows = platform === 'win32'; const binaryName = isWindows ? 'vp.exe' : 'vp'; @@ -155,25 +185,27 @@ for (const napiTarget of pkg.napi.targets) { console.warn( `Warning: Rust binary not found at ${rustBinarySource}, skipping CLI package for ${platformArchABI}`, ); - continue; + return; } - // Create temp directory for CLI package const platformCliDir = join(cliNpmDir, platformArchABI); mkdirSync(platformCliDir, { recursive: true }); - // Copy binary copyFileSync(rustBinarySource, join(platformCliDir, binaryName)); if (!isWindows) { chmodSync(join(platformCliDir, binaryName), 0o755); } - // Copy trampoline shim binary for Windows (required) - // The trampoline is a small exe that replaces .cmd wrappers to avoid - // "Terminate batch job (Y/N)?" on Ctrl+C (see issue #835) - const shimName = 'vp-shim.exe'; const files = [binaryName]; + if (!skipNpmPublish) { + validateInstallLockfile(readFileSync(lockfile, 'utf8'), cliVersion); + copyFileSync(lockfile, join(platformCliDir, INSTALL_LOCKFILE)); + files.push(INSTALL_LOCKFILE); + } if (isWindows) { + // The trampoline replaces .cmd wrappers to avoid "Terminate batch job (Y/N)?" + // on Ctrl+C (see issue #835). + const shimName = 'vp-shim.exe'; const shimSource = join(repoRoot, 'target', napiTarget, 'release', shimName); if (!existsSync(shimSource)) { console.error( @@ -193,6 +225,7 @@ for (const napiTarget of pkg.napi.targets) { cpu: [arch], ...(abi ? { libc: [abi] } : {}), files, + ...(!skipNpmPublish ? { vpInstallLockfile: true } : {}), description: `Vite+ CLI binary for ${platformArchABI}`, repository: cliPackageJson.repository, }; @@ -204,10 +237,9 @@ for (const napiTarget of pkg.napi.targets) { console.log( `Prepared CLI package: @voidzero-dev/vite-plus-cli-${platformArchABI}@${cliVersion}`, ); - continue; + return; } - // Publish CLI package const result = await publishNpmPackageFromEnv(cliPackage, 'npm', publishArgs, platformCliDir); if (result === 'published') { @@ -215,12 +247,3 @@ for (const napiTarget of pkg.napi.targets) { console.log(`Published CLI package: @voidzero-dev/vite-plus-cli-${platform}@${cliVersion}`); } } - -// npm can accept uploads before scanning makes them installable. Wait for the -// platform packages before publishing core and the CLI, which pin their versions. -if (!skipNpmPublish) { - await waitForNpmPackagesFromEnv(platformPackages); - - // Preview releases still need the prepared directories. - rmSync(cliNpmDir, { recursive: true, force: true }); -} diff --git a/packages/cli/src/__tests__/install-lockfile.spec.ts b/packages/cli/src/__tests__/install-lockfile.spec.ts new file mode 100644 index 0000000000..f95445734d --- /dev/null +++ b/packages/cli/src/__tests__/install-lockfile.spec.ts @@ -0,0 +1,211 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { createTarGzip } from 'nanotar'; +import { describe, expect, it, vi } from 'vitest'; + +import { + addInstallLockfileToArchives, + generateInstallLockfile, + INSTALL_LOCKFILE, + installManifest, + publishedInstallLockfile, + validateInstallLockfile, +} from '../../generate-install-lockfile.ts'; + +const exec = promisify(execFile); +const version = '1.4.0'; +const lockfile = `lockfileVersion: '9.0' +importers: + .: + dependencies: + vite-plus: + specifier: ${version} + version: ${version} +packages: + vite-plus@${version}: + resolution: {integrity: sha512-fixture} +`; + +describe('release installation lockfile', () => { + it('generates an isolated global wrapper with the installer pnpm version', async () => { + let temporary = ''; + const result = await generateInstallLockfile( + version, + 'https://registry.example', + async (directory, args, env) => { + temporary = directory; + expect(JSON.parse(await readFile(join(directory, 'package.json'), 'utf8'))).toEqual( + installManifest(version), + ); + expect(await readFile(join(directory, '.npmrc'), 'utf8')).toBe('minimum-release-age=0\n'); + expect(args).toEqual([ + 'install', + '--ignore-workspace', + '--config.auto-install-peers=true', + '--config.exclude-links-from-lockfile=false', + '--config.dedupe-peers=false', + '--config.inject-workspace-packages=false', + '--lockfile-only', + '--ignore-scripts', + '--no-frozen-lockfile', + '--registry', + 'https://registry.example', + ]); + expect(env).toEqual({ + npm_config_peers_suffix_max_length: '1000', + NPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH: '1000', + }); + // A repository workspace lockfile cannot be reused for this wrapper. + await expect(access(join(directory, 'pnpm-lock.yaml'))).rejects.toThrow(); + await writeFile(join(directory, 'pnpm-lock.yaml'), lockfile); + }, + ); + expect(result).toBe(lockfile); + await expect(access(temporary)).rejects.toThrow(); + const rust = await readFile( + new URL('../../../../crates/vp_setup/src/install.rs', import.meta.url), + 'utf8', + ); + expect(rust).toContain( + `const PINNED_PNPM_VERSION: &str = "${installManifest(version).packageManager.slice(5)}";`, + ); + for (const name of ['install-legacy.sh', 'install-legacy.ps1']) { + const script = await readFile(new URL(`../../${name}`, import.meta.url), 'utf8'); + expect(script).toContain(`"${installManifest(version).packageManager}"`); + } + }); + + it('cleans up and stops when resolution fails', async () => { + let temporary = ''; + await expect( + generateInstallLockfile(version, 'https://registry.example', async (directory) => { + temporary = directory; + throw new Error('registry unavailable'); + }), + ).rejects.toThrow('registry unavailable'); + await expect(access(temporary)).rejects.toThrow(); + }); + + it('rejects a lockfile for a different wrapper or without the CLI integrity', () => { + expect(() => validateInstallLockfile(lockfile, version)).not.toThrow(); + expect(() => validateInstallLockfile(lockfile, '1.5.0')).toThrow( + 'Invalid global installation lockfile', + ); + expect(() => + validateInstallLockfile( + lockfile.replace('integrity: sha512-fixture', 'tarball: file:cli.tgz'), + version, + ), + ).toThrow('Invalid global installation lockfile'); + expect(() => + validateInstallLockfile( + lockfile.replace( + 'integrity: sha512-fixture', + 'integrity: sha512-fixture, tarball: http://localhost:1234/cli.tgz', + ), + version, + ), + ).toThrow('Invalid global installation lockfile'); + expect(() => + validateInstallLockfile( + lockfile.replace( + 'integrity: sha512-fixture', + 'integrity: sha512-fixture, tarball: https://registry.npmjs.org/vite-plus/-/vite-plus-1.4.0.tgz', + ), + version, + ), + ).toThrow('Invalid global installation lockfile'); + }); + + it('recovers the published lockfile after a partially completed release', async () => { + const archive = Buffer.from( + await createTarGzip([{ name: `package/${INSTALL_LOCKFILE}`, data: lockfile }]), + ); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce( + Response.json({ + vpInstallLockfile: true, + dist: { + tarball: 'https://registry.example/cli.tgz', + integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}`, + }, + }), + ) + .mockResolvedValueOnce(new Response(archive)); + await expect( + publishedInstallLockfile( + version, + 'https://registry.example/', + ['@scope/first', '@scope/second'], + fetchImpl, + ), + ).resolves.toBe(lockfile); + expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([ + `https://registry.example/@scope/first/${version}`, + `https://registry.example/@scope/second/${version}`, + 'https://registry.example/cli.tgz', + ]); + }); + + it('does not generate a different lockfile on registry errors or corrupt published archives', async () => { + await expect( + publishedInstallLockfile( + version, + 'https://registry.example', + ['@scope/cli'], + async () => new Response(null, { status: 503 }), + ), + ).rejects.toThrow('HTTP 503'); + await expect( + publishedInstallLockfile(version, 'https://registry.example', ['@scope/cli'], async () => + Response.json({}), + ), + ).rejects.toThrow('published without an installation lockfile'); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + vpInstallLockfile: true, + dist: { tarball: 'https://registry.example/cli.tgz', integrity: 'sha512-expected' }, + }), + ) + .mockResolvedValueOnce(new Response('modified archive')); + await expect( + publishedInstallLockfile(version, 'https://registry.example', ['@scope/cli'], fetchImpl), + ).rejects.toThrow('Integrity mismatch'); + }); + + it.runIf(process.platform !== 'win32')( + 'adds the same lockfile to tar and zip releases without losing binary permissions', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'vp-release-lock-test-')); + try { + await writeFile(join(directory, 'vp'), '#!/bin/sh\n'); + await chmod(join(directory, 'vp'), 0o755); + await writeFile(join(directory, INSTALL_LOCKFILE), lockfile); + await exec('tar', ['-czf', join(directory, 'vp-linux.tar.gz'), '-C', directory, 'vp']); + await exec('zip', ['-j', join(directory, 'vp-windows.zip'), join(directory, 'vp')]); + await addInstallLockfileToArchives(directory); + expect( + (await exec('tar', ['-xOf', join(directory, 'vp-linux.tar.gz'), INSTALL_LOCKFILE])) + .stdout, + ).toBe(lockfile); + expect( + (await exec('unzip', ['-p', join(directory, 'vp-windows.zip'), INSTALL_LOCKFILE])).stdout, + ).toBe(lockfile); + expect((await exec('tar', ['-tvf', join(directory, 'vp-linux.tar.gz')])).stdout).toMatch( + /-rwxr-xr-x.* vp/, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/packages/cli/src/__tests__/installation-publishing.spec.ts b/packages/cli/src/__tests__/installation-publishing.spec.ts new file mode 100644 index 0000000000..23a38fbc4a --- /dev/null +++ b/packages/cli/src/__tests__/installation-publishing.spec.ts @@ -0,0 +1,169 @@ +import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; + +import { describe, expect, it, vi } from 'vitest'; +import { parse } from 'yaml'; + +interface Step { + name?: string; + id?: string; + uses?: string; + run?: string; + if?: string; + with?: Record; +} + +interface Job { + steps: Step[]; + needs?: string | string[]; + permissions?: Record; + environment?: string; +} + +async function workflow(name: string): Promise<{ jobs: Record }> { + return parse( + await readFile(new URL(`../../../../.github/workflows/${name}.yml`, import.meta.url), 'utf8'), + ); +} + +function bridge(job: Job): Step { + return job.steps.find((step) => step.with?.mode === 'upload')!; +} + +describe('installation publishing order', () => { + it('allows build artifact uploads to repeat but preserves publication artifacts', async () => { + for (const name of ['reusable-release-build', 'publish-preview']) { + const { jobs } = await workflow(name); + const uploads = Object.values(jobs).flatMap((job) => + (job.steps ?? []).filter((step) => step.uses?.startsWith('actions/upload-artifact@')), + ); + expect(uploads.length).toBeGreaterThan(0); + for (const upload of uploads) { + expect(upload.with?.overwrite).toBe(true); + } + } + const release = (await workflow('release')).jobs.Release; + const lockfile = release.steps.find((step) => step.with?.name === 'installation-lockfile')!; + expect(lockfile.with?.overwrite).not.toBe(true); + expect(lockfile.if).toBe("steps.install-lockfile.outputs.restored != 'true'"); + const preview = (await workflow('publish-preview-register')).jobs['prepare-global']; + const prepared = preview.steps.find((step) => step.with?.name === 'prepared-preview-cli')!; + expect(prepared.with?.overwrite).not.toBe(true); + expect(prepared.if).toBe("steps.prepared.outputs.exists != 'true'"); + }); + + it.each(['missing', 'draft', 'published', 'unavailable'])( + 'handles a %s GitHub release when retrying asset publication', + async (state) => { + const { steps } = (await workflow('release')).jobs.Release; + const check = steps.find((step) => step.id === 'github-release')!; + const getReleaseByTag = vi.fn(async () => { + if (state === 'missing' || state === 'unavailable') { + throw Object.assign(new Error(state), { status: state === 'missing' ? 404 : 503 }); + } + return { data: { draft: state === 'draft' } }; + }); + const setOutput = vi.fn(); + const result = runInNewContext(`(async () => { ${check.with!.script} })()`, { + github: { rest: { repos: { getReleaseByTag } } }, + context: { repo: { owner: 'voidzero-dev', repo: 'vite-plus' } }, + core: { setOutput }, + process: { env: { VERSION: '1.4.0' } }, + }) as Promise; + if (state === 'unavailable') { + await expect(result).rejects.toThrow('unavailable'); + expect(setOutput).not.toHaveBeenCalled(); + } else { + await result; + expect(setOutput).toHaveBeenCalledExactlyOnceWith( + 'published', + String(state === 'published'), + ); + } + expect(getReleaseByTag).toHaveBeenCalledExactlyOnceWith({ + owner: 'voidzero-dev', + repo: 'vite-plus', + tag: 'v1.4.0', + }); + for (const name of [ + 'Create release body', + 'Create GitHub Release', + 'Publish GitHub Release', + ]) { + const step = steps.find((step) => step.name === name)!; + expect(step.if).toBe("steps.github-release.outputs.published != 'true'"); + } + const upload = steps.find((step) => step.name === 'Create GitHub Release')!; + expect(upload.with?.overwrite_files).toBe(true); + expect(upload.with?.fail_on_unmatched_files).toBe(true); + }, + ); + + it('resolves the staged local CLI before publishing global binaries and moving latest', async () => { + const { steps } = (await workflow('release')).jobs.Release; + const publishLocal = steps.findIndex((step) => step.run?.includes('--tag staging')); + const waitLocal = steps.findIndex( + (step) => step.name === 'Wait for the local CLI to propagate', + ); + const lock = steps.findIndex( + (step) => step.run === 'node ./packages/cli/generate-install-lockfile.ts', + ); + const save = steps.findIndex((step) => step.with?.name === 'installation-lockfile'); + const publishGlobal = steps.findIndex((step) => step.run?.includes('--phase cli')); + const promote = steps.findIndex((step) => step.run?.startsWith('npm dist-tag add ')); + expect(publishLocal).toBeGreaterThan(-1); + expect(waitLocal).toBeGreaterThan(publishLocal); + expect(lock).toBeGreaterThan(waitLocal); + expect(save).toBeGreaterThan(lock); + expect(publishGlobal).toBeGreaterThan(save); + expect(promote).toBeGreaterThan(publishGlobal); + expect(steps[promote].run).toBe('npm dist-tag add "vite-plus@${VERSION}" latest'); + expect(steps.some((step) => step.run?.includes('local-npm-registry'))).toBe(false); + expect(steps.some((step) => step.run === 'npm install --global npm@11.21.0')).toBe(true); + expect(steps[save].with?.path).toBe('binary-release/vp-install-lock.yaml'); + }); + + it('separates preview publication from pnpm resolution and moves the PR tag last', async () => { + const build = await workflow('publish-preview'); + const publish = await workflow('publish-preview-register'); + const local = publish.jobs['publish-local']; + const prepare = publish.jobs['prepare-global']; + const global = publish.jobs.publish; + expect(local.needs).toBe('authorize'); + expect(local.environment).toContain('preview-build-release'); + expect(prepare.needs).toContain('publish-local'); + expect(global.needs).toContain('prepare-global'); + expect(global.needs).toContain('publish-local'); + expect(prepare.permissions).toEqual({ contents: 'read', actions: 'read' }); + for (const job of [local, global]) { + expect(job.permissions?.['id-token']).toBe('write'); + expect(job.steps.some((step) => step.run)).toBe(false); + expect(job.steps[0].name).toBe('Re-check authorization before publish'); + expect(bridge(job).with?.sha).toBe('${{ github.event.workflow_run.head_sha }}'); + } + expect(bridge(local).with?.['pr-url']).toBeUndefined(); + expect(bridge(global).with?.['pr-url']).toBe('${{ needs.authorize.outputs.pr-url }}'); + expect(bridge(global).with?.['workspace-packages']).toBe('@voidzero-dev/vite-plus-cli-*'); + + const packs = build.jobs.publish.steps.filter((step) => step.with?.mode === 'pack'); + expect(packs).toHaveLength(2); + expect(packs[0].with?.packages).not.toContain('packages/cli/cli-npm/*'); + expect(packs[1].with?.packages).toBe('packages/cli/cli-npm/*'); + expect(new Set([...packs, bridge(local), bridge(global)].map((step) => step.uses)).size).toBe( + 1, + ); + + const generate = prepare.steps.find((step) => + step.run?.includes('generate-preview-install-lockfile'), + )!; + expect(generate.if).toBe("steps.prepared.outputs.exists != 'true'"); + const save = prepare.steps.find((step) => step.with?.name === 'prepared-preview-cli')!; + expect(save.if).toBe(generate.if); + expect( + global.steps.find((step) => step.with?.name === 'prepared-preview-cli')?.with?.['run-id'], + ).toBe('${{ github.run_id }}'); + expect( + prepare.steps.find((step) => step.with?.name === 'bridge-cli-packages')?.with?.['run-id'], + ).toBe('${{ github.event.workflow_run.id }}'); + }); +}); diff --git a/packages/cli/src/__tests__/preview-install-lockfile.spec.ts b/packages/cli/src/__tests__/preview-install-lockfile.spec.ts new file mode 100644 index 0000000000..12447ee327 --- /dev/null +++ b/packages/cli/src/__tests__/preview-install-lockfile.spec.ts @@ -0,0 +1,206 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createTarGzip, parseTarGzip } from 'nanotar'; +import { describe, expect, it, vi } from 'vitest'; +import { stringify } from 'yaml'; + +import { + INSTALL_LOCKFILE, + PREVIEW_REGISTRY, + validateInstallLockfile, +} from '../../generate-install-lockfile.ts'; +import { preparePreviewPackages } from '../../generate-preview-install-lockfile.ts'; + +const version = '0.0.0-commit.0123456789abcdef0123456789abcdef01234567'; +const names = [ + '@voidzero-dev/vite-plus-cli-linux-x64-gnu', + '@voidzero-dev/vite-plus-cli-win32-arm64-msvc', +]; +const decoder = new TextDecoder(); +const noPublishedLockfile = async () => undefined; +const shasum = 'a'.repeat(40); +const lockfile = stringify({ + lockfileVersion: '9.0', + importers: { '.': { dependencies: { 'vite-plus': { specifier: version, version } } } }, + packages: { + [`vite-plus@${version}`]: { + resolution: { + integrity: 'sha512-published-cli', + tarball: `${PREVIEW_REGISTRY}/tarballs/vite-plus/${version}/${shasum}.tgz`, + }, + }, + // Pnpm keeps these URLs because the bridge proxies npm package metadata. + 'sirv@3.0.2': { + resolution: { + integrity: 'sha512-published-sirv', + tarball: 'https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz', + }, + }, + '@babel/code-frame@7.29.7': { + resolution: { + integrity: 'sha512-published-code-frame', + tarball: 'https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz', + }, + }, + }, +}); + +async function withBatch(run: (directory: string) => Promise) { + const directory = await mkdtemp(join(tmpdir(), 'vp-preview-test-')); + try { + for (const [index, name] of names.entries()) { + await writeFile( + join(directory, `pkg-${index}.tgz`), + await createTarGzip([ + { name: 'package/package.json', data: JSON.stringify({ name, version, files: ['vp'] }) }, + { name: 'package/vp', data: 'binary', attrs: { mode: '755' } }, + ]), + ); + } + await run(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +describe('preview installation lockfile', () => { + it('resolves the published commit version and bundles the unchanged pnpm lockfile', async () => { + await withBatch(async (directory) => { + const resolveLockfile = vi.fn(async () => lockfile); + expect( + await preparePreviewPackages(directory, version, resolveLockfile, noPublishedLockfile), + ).toBe(lockfile); + expect(resolveLockfile).toHaveBeenCalledExactlyOnceWith(version, PREVIEW_REGISTRY); + expect(await readdir(directory)).toEqual(['pkg-0.tgz', 'pkg-1.tgz']); + for (const index of [0, 1]) { + const entries = await parseTarGzip( + new Uint8Array(await readFile(join(directory, `pkg-${index}.tgz`))), + ); + const manifest = JSON.parse( + decoder.decode(entries.find((entry) => entry.name === 'package/package.json')!.data), + ); + expect(manifest.vpInstallLockfile).toBe(true); + expect(manifest.files).toEqual(['vp', INSTALL_LOCKFILE]); + expect( + decoder.decode( + entries.find((entry) => entry.name === `package/${INSTALL_LOCKFILE}`)!.data, + ), + ).toBe(lockfile); + const binary = entries.find((entry) => entry.name === 'package/vp')!; + expect(decoder.decode(binary.data)).toBe('binary'); + expect(Number.parseInt(binary.attrs!.mode!, 8)).toBe(0o755); + } + }); + }); + + it('leaves the archives unchanged when the published local CLI cannot be resolved', async () => { + await withBatch(async (directory) => { + const before = await readFile(join(directory, 'pkg-0.tgz')); + await expect( + preparePreviewPackages( + directory, + version, + async () => { + throw new Error('local CLI is not published'); + }, + noPublishedLockfile, + ), + ).rejects.toThrow('local CLI is not published'); + expect(await readFile(join(directory, 'pkg-0.tgz'))).toEqual(before); + }); + }); + + it('does not repack local CLI dependency archives', async () => { + await withBatch(async (directory) => { + const archive = await createTarGzip([ + { name: 'package/package.json', data: JSON.stringify({ name: 'vite-plus', version }) }, + ]); + await writeFile(join(directory, 'pkg-2.tgz'), archive); + const resolveLockfile = vi.fn(async () => lockfile); + await expect( + preparePreviewPackages(directory, version, resolveLockfile, noPublishedLockfile), + ).rejects.toThrow('Expected unique global CLI platform packages'); + expect(resolveLockfile).not.toHaveBeenCalled(); + expect(new Uint8Array(await readFile(join(directory, 'pkg-2.tgz')))).toEqual(archive); + }); + }); + + it('rejects a circular hash dependency on the binary package carrying the lockfile', async () => { + await withBatch(async (directory) => { + const circular = lockfile + ` "${names[0]}@${version}": {}\n`; + await expect( + preparePreviewPackages(directory, version, async () => circular, noPublishedLockfile), + ).rejects.toThrow('must not depend on @voidzero-dev/vite-plus-cli-'); + }); + }); + + it('replaces an existing companion without duplicate archive or manifest entries', async () => { + await withBatch(async (directory) => { + await preparePreviewPackages(directory, version, async () => lockfile, noPublishedLockfile); + await preparePreviewPackages(directory, version, async () => lockfile, noPublishedLockfile); + const entries = await parseTarGzip( + new Uint8Array(await readFile(join(directory, 'pkg-0.tgz'))), + ); + expect(entries.filter((entry) => entry.name === `package/${INSTALL_LOCKFILE}`)).toHaveLength( + 1, + ); + const manifest = JSON.parse( + decoder.decode(entries.find((entry) => entry.name === 'package/package.json')!.data), + ); + expect(manifest.files).toEqual(['vp', INSTALL_LOCKFILE]); + }); + }); + + it('reuses the published lockfile after a partial publication without a saved artifact', async () => { + await withBatch(async (directory) => { + const resolveLockfile = vi.fn(async () => lockfile.replace('published-cli', 'new-cli')); + const recoverLockfile = vi.fn(async () => lockfile); + expect( + await preparePreviewPackages(directory, version, resolveLockfile, recoverLockfile), + ).toBe(lockfile); + expect(recoverLockfile).toHaveBeenCalledExactlyOnceWith(version, PREVIEW_REGISTRY, names); + expect(resolveLockfile).not.toHaveBeenCalled(); + for (const file of await readdir(directory)) { + const entries = await parseTarGzip(new Uint8Array(await readFile(join(directory, file)))); + expect( + decoder.decode( + entries.find((entry) => entry.name === `package/${INSTALL_LOCKFILE}`)!.data, + ), + ).toBe(lockfile); + } + }); + }); + + it('does not resolve again when recovery of an existing publication fails', async () => { + await withBatch(async (directory) => { + const before = await readFile(join(directory, 'pkg-0.tgz')); + const resolveLockfile = vi.fn(async () => lockfile); + await expect( + preparePreviewPackages(directory, version, resolveLockfile, async () => { + throw new Error('registry unavailable'); + }), + ).rejects.toThrow('registry unavailable'); + expect(resolveLockfile).not.toHaveBeenCalled(); + expect(await readFile(join(directory, 'pkg-0.tgz'))).toEqual(before); + }); + }); + + it('accepts npm dependencies while keeping preview packages on content-addressed bridge URLs', () => { + expect(() => validateInstallLockfile(lockfile, version)).not.toThrow(); + for (const changed of [ + lockfile.replace(PREVIEW_REGISTRY, 'http://localhost:1234'), + lockfile.replace(`/tarballs/vite-plus/${version}/`, '/tarballs/vite-plus/0.0.0-commit.old/'), + lockfile.replace(`/${shasum}.tgz`, '.tgz'), + lockfile.replace(PREVIEW_REGISTRY, 'https://registry.npmjs.org'), + lockfile.replace('https://registry.npmjs.org/', 'http://localhost:1234/'), + lockfile.replace('https://registry.npmjs.org/', 'https://registry.npmjs.org.example/'), + lockfile.replace('integrity: sha512-published-sirv', 'integrity: ""'), + ]) { + expect(() => validateInstallLockfile(changed, version)).toThrow( + 'Invalid global installation lockfile', + ); + } + }); +}); diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index 24ec5083a5..44a2c577bd 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -361,6 +361,20 @@ Integrations that need Vite+-managed paths must get them from the binary through platform rules. An external package manager can keep its own installation prefix and leave the Vite+ directory variables unset. +#### Installation lockfile + +Managed installations retain the published companion at +`//bin/vp-install-lock.yaml`, beside the binary. Before a frozen +dependency install, setup restores it as `//pnpm-lock.yaml`. +These paths apply to both split and monolithic layouts. Successful frozen +installs record the companion's SHA-256 digest in +`//node_modules/.vp-install-complete`. This receipt survives a +reset of the setup marker, so upgrade handoffs reuse installed dependencies. +Without a matching receipt, setup must complete a frozen install even if a +failed attempt left `node_modules` or a working lockfile behind. +See [Global installation lockfile](./merge-global-and-local-cli.md#global-installation-lockfile) +for package contents, recovery, and compatibility behavior. + #### First-run setup for external binaries Managed installations record completed setup in diff --git a/rfcs/merge-global-and-local-cli.md b/rfcs/merge-global-and-local-cli.md index 6f55ec51d8..020f52f1f5 100644 --- a/rfcs/merge-global-and-local-cli.md +++ b/rfcs/merge-global-and-local-cli.md @@ -70,8 +70,10 @@ This decouples the `vp` binary from vite-plus's internal file layout. ├── current -> / # Symlink to active version ├── / │ ├── bin/ -│ │ └── vp # Rust binary (from CLI platform package) +│ │ ├── vp # Rust binary (from CLI platform package) +│ │ └── vp-install-lock.yaml # Published installation lockfile │ ├── package.json # Wrapper: { "dependencies": { "vite-plus": "" } } +│ ├── pnpm-lock.yaml # Working copy restored from bin/vp-install-lock.yaml │ └── node_modules/ │ ├── vite-plus/ # Installed as npm dependency │ │ ├── dist/bin.js # JS entry point (found by Rust binary) @@ -90,12 +92,17 @@ This decouples the `vp` binary from vite-plus's internal file layout. **Install flows:** - **Production** (`curl -fsSL https://vite.plus | bash`): - Downloads CLI platform tarball from `@voidzero-dev/vite-plus-cli-{platform}` (extracts only `vp` binary), - generates wrapper `package.json`, runs `vp install --silent` which installs `vite-plus` + all transitive deps via npm. + Downloads the CLI platform archive from `@voidzero-dev/vite-plus-cli-{platform}` + and extracts the binary and its bundled `vp-install-lock.yaml`. The script + delegates to the binary's self-setup, which generates the wrapper `package.json`, + restores `pnpm-lock.yaml`, and installs dependencies with managed pnpm and + `--frozen-lockfile`. `install.ps1` uses the same flow on Windows. - **Upgrade** (`vp upgrade`): - Downloads CLI platform tarball from `@voidzero-dev/vite-plus-cli-{platform}` (binary only), - generates wrapper `package.json`, runs `vp install --silent`. No main tarball download needed. + Downloads the CLI platform archive with the binary and lockfile, generates the + wrapper `package.json`, and restores the bundled lockfile before the frozen + dependency install. pnpm downloads `vite-plus` and its dependencies using the + versions and integrity values recorded in that lockfile. - **Local dev** (`pnpm bootstrap-cli`): Copies `vp` binary, generates wrapper `package.json`, symlinks @@ -106,6 +113,63 @@ This decouples the `vp` binary from vite-plus's internal file layout. Copies `vp` binary, generates wrapper `package.json` with `file:` protocol refs to tgz files, runs `npm install`. +### Global Installation Lockfile + +Each `@voidzero-dev/vite-plus-cli-{platform}` package contains +`vp-install-lock.yaml` beside `vp` or `vp.exe`. Release and commit preview builds +both use this layout. Standalone release archives also include the companion. +The lockfile describes the global wrapper's `vite-plus` dependency and its +transitive dependencies, including optional packages for all supported platforms. +CLI binary packages remain outside this dependency graph, so adding the lockfile +to them cannot change an integrity value recorded inside it. + +Release builds publish the local CLI under `staging`, then use the installer's +pinned pnpm version to generate a standard lockfile from that published version. +The workflow promotes the local CLI to `latest` after the global packages are +available. Preview builds also publish the local CLI first, generate the lockfile +from the registry, then publish the global packages and update the PR tag. +Publishing retries reuse the prepared artifacts. +See [Global installation lockfile](../packages/cli/BUNDLING.md#global-installation-lockfile) +for the publication order and generator entry points. + +The platform manifest advertises the companion with `vpInstallLockfile: true`. +Extraction fails if an advertised companion is missing. A standalone executable +or an older upgrade client can lose the companion; self-setup recovers it from +the exact platform package and verifies that archive's integrity. Companion +writes use a temporary file and atomic rename, so interrupted writes can be retried. +A successful frozen install records the companion's SHA-256 digest in +`node_modules/.vp-install-complete`. Self-setup reuses that install after an +upgrade or a later setup failure. Missing receipts or changed lockfiles require +another frozen install, even when `node_modules` already exists. Each install +attempt clears the receipt before it starts. Older packages without a companion +retain their existing installation behavior. Local development and already +bundled external installations keep their existing setup paths. + +The installation's `.npmrc` keeps `minimum-release-age=0` so a newly published +version can be installed immediately. This does not change the versions recorded +in the installation lockfile. Generation and frozen installation pin all five +settings that pnpm checks in `lockfile.settings`: +`auto-install-peers=true`, `peers-suffix-max-length=1000`, +`exclude-links-from-lockfile=false`, `dedupe-peers=false`, and +`inject-workspace-packages=false`. Boolean values use command-line flags. The +numeric peer suffix limit uses subprocess environment overrides because pnpm +parses `--config` numbers as strings. Both override user files and inherited +environment values. Registry, authentication, and proxy settings remain inherited. +Frozen installation also sets `--lockfile=true` so user configuration cannot +disable the published lockfile. + +`VP_SETUP_SKIP_INSTALL_LOCKFILE=1` is an explicit emergency override for setup and +upgrades. It skips companion extraction, recovery, and restoration, then runs +pnpm with `--no-frozen-lockfile --lockfile=false`. The installer warns that it is +resolving dependencies again. The wrapper still pins the requested CLI version. +There is no automatic fallback after a frozen install fails. + +A successful bypass writes `unlocked` to `node_modules/.vp-install-complete`, +instead of a lockfile digest. This lets upgrade handoff finish without repeating +dependency installation while the override is set. A later setup without the +override cannot reuse this receipt as a frozen install. Failed attempts clear +the receipt before running pnpm, as they do for frozen installs. + ### Command Routing The Rust `vp` binary (`crates/vp_global_cli/`) parses every command with clap (`crates/vp_global_cli/src/cli.rs`) and routes it down one of the paths below: @@ -223,7 +287,7 @@ if (command === 'create') { - `.node` NAPI binaries installed via npm optionalDependencies (not manually copied) - Removed `extract_main_package()`, `strip_dev_dependencies()`, `MAIN_PACKAGE_ENTRIES` - Added `generate_wrapper_package_json()` for upgrade command - - Simplified install scripts: only extract `vp` binary + generate wrapper + - Install scripts extract `vp` and its companion lockfile, then delegate permanent installation to self-setup - Simplified `install-global-cli.ts`: symlink-based local dev, wrapper-based CI 5. **Updated build system**: @@ -249,7 +313,7 @@ if (command === 'create') { 10. **Separated `vp` binary into dedicated CLI platform packages**: - `@voidzero-dev/vite-plus-{platform}` packages now contain only the `.node` NAPI binding (~20MB) - - `@voidzero-dev/vite-plus-cli-{platform}` packages contain only the `vp` Rust binary (~5MB) + - `@voidzero-dev/vite-plus-cli-{platform}` packages contain the `vp` Rust binary and its installation lockfile; Windows packages also include `vp-shim.exe` - `publish-native-addons.ts` creates and publishes both NAPI and CLI packages separately - Install scripts (`install.sh`, `install.ps1`) construct CLI package suffix directly instead of querying optionalDependencies - Upgrade registry (`registry.rs`) queries CLI packages directly instead of looking up optionalDependencies diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 1160a4d621..5ed1501ed6 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -22,8 +22,9 @@ A native `vp upgrade` command would allow users to update the CLI in-place with ├── current → 0.1.0/ # Symlink to active version ├── 0.1.0/ # Version directory │ ├── bin/vp # Actual binary -│ ├── dist/ # JS bundles + .node files -│ ├── package.json +│ ├── bin/vp-install-lock.yaml # Published installation lockfile +│ ├── package.json # Wrapper with an exact vite-plus dependency +│ ├── pnpm-lock.yaml # Restored from the published companion │ └── node_modules/ ├── 0.0.9/ # Previous version (kept for rollback) ├── env # POSIX shell env (sourced by shell config) @@ -147,22 +148,23 @@ Options: ### Architecture -The upgrade command is implemented entirely in Rust within the `vp_global_cli` crate, mirroring the logic of `install.sh` but running as a native subprocess workflow. +The upgrade command uses `vp_global_cli` and the shared `vp_setup` library. +It installs the same binary and bundled lockfile as the script installers. ``` -┌─────────────────────────────────────────────────┐ -│ vp upgrade │ -├─────────────────────────────────────────────────┤ -│ 1. Resolve version (npm registry query) │ -│ 2. Check if already installed │ -│ 3. Download platform binary (.tgz) │ -│ 4. Download main JS bundle (.tgz) │ -│ 5. Extract to ~/.vite-plus/{version}/ │ -│ 6. Install production dependencies │ -│ 7. Atomic swap: current → {version} │ -│ 8. Refresh shims (non-fatal) │ -│ 9. Cleanup old versions (non-fatal, keep 3) │ -└─────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────┐ +│ vp upgrade │ +├──────────────────────────────────────────────────────┤ +│ 1. Resolve version (registry query) │ +│ 2. Check if already installed │ +│ 3. Download and verify platform archive │ +│ 4. Extract binary + vp-install-lock.yaml │ +│ 5. Generate wrapper and restore pnpm-lock.yaml │ +│ 6. Install dependencies with --frozen-lockfile │ +│ 7. Swap current → {version} │ +│ 8. Refresh shims (non-fatal) │ +│ 9. Cleanup old versions (non-fatal, keep 3) │ +└──────────────────────────────────────────────────────┘ ``` ### Implementation Flow @@ -172,17 +174,14 @@ The upgrade command is implemented entirely in Rust within the `vp_global_cli` c Query the npm registry for the target version: ``` -GET {registry}/vite-plus-cli/{version_or_tag} +GET {registry}/vite-plus/{version_or_tag} ``` - If `VERSION` arg is provided, use it directly - If `--tag` is provided, resolve that dist-tag (e.g., `latest`, `test`) - Default to `latest` -Parse the JSON response to extract: - -- `version`: the resolved semver version -- `optionalDependencies`: to find the platform-specific package name +Read `version` from the response. #### Step 2: Version Comparison @@ -193,19 +192,24 @@ Compare the resolved version against the currently running binary's version (`en #### Step 3: Download and Verify -Download two tarballs from the npm registry: +Detect the platform suffix and query +`@voidzero-dev/vite-plus-cli-{platform_suffix}` for the exact target version. +Download the `dist.tarball` URL from that package's registry metadata. +The archive contains `vp` or `vp.exe`, the bundled +`vp-install-lock.yaml`, and `vp-shim.exe` on Windows. The package manifest sets +`vpInstallLockfile: true` to advertise the companion. Release and commit preview +packages use this layout; preview metadata supplies a content-addressed URL. -1. **Platform binary**: `{registry}/@voidzero-dev/vite-plus-cli-{platform_suffix}/-/vite-plus-cli-{suffix}-{version}.tgz` - - Contains: `vp` binary + `.node` NAPI files -2. **Main package**: `{registry}/vite-plus-cli/-/vite-plus-cli-{version}.tgz` - - Contains: `dist/` (JS bundles), `package.json`, `templates/`, `rules/`, `AGENTS.md` +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. **Integrity verification**: Each tarball is verified against the `integrity` field from the npm registry metadata. The npm registry provides SHA-512 hashes in the [Subresource Integrity](https://w3c.github.io/webappsec-subresource-integrity/) format: ```json { "dist": { - "tarball": "https://registry.npmjs.org/vite-plus-cli/-/vite-plus-cli-0.0.0-xxx.tgz", + "tarball": "https://registry.npmjs.org/@voidzero-dev/vite-plus-cli-darwin-arm64/-/vite-plus-cli-darwin-arm64-1.4.0.tgz", "integrity": "sha512-Z3se9k/NTRf8s5eSmuSoMOFFB/TUGBHIoeWDU5VoHV...", "shasum": "3399579218148ae410011bde8934e12209743ef3" } @@ -242,10 +246,10 @@ fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> { } ``` -To get the `integrity` field for the platform package, we need to query its metadata separately: - -- Main package metadata: `{registry}/vite-plus-cli/{version}` → contains `dist.integrity` -- Platform package metadata: `{registry}/@voidzero-dev/vite-plus-cli-{suffix}/{version}` → contains `dist.integrity` +Platform package metadata at +`{registry}/@voidzero-dev/vite-plus-cli-{suffix}/{version}` supplies the archive's +`dist.integrity`. The bundled lockfile supplies integrity values for the +JavaScript packages that pnpm installs. Platform detection reuses existing logic from `vp_js_runtime` or mirrors the bash script's approach: @@ -255,12 +259,19 @@ Platform detection reuses existing logic from `vp_js_runtime` or mirrors the bas #### Step 4: Extract and Install -1. Create `~/.vite-plus/{version}/` with `bin/` and `dist/` subdirectories -2. Extract platform binary to `{version}/bin/vp`, set executable permissions -3. Extract `.node` files to `{version}/dist/` -4. Extract JS bundle, templates, rules, package.json to `{version}/` -5. Strip `devDependencies` and `optionalDependencies` from package.json -6. Run `vp install --silent` in the version directory to install production dependencies +1. Create the version directory under the data root resolved by `VpDirs`, with a `bin/` subdirectory. +2. Extract the binary and `vp-install-lock.yaml` into `bin/`, along with `vp-shim.exe` on Windows. +3. Generate the wrapper `package.json` with an exact `vite-plus` dependency and the installer's pinned pnpm version. +4. Restore `bin/vp-install-lock.yaml` as `pnpm-lock.yaml` in the version directory. +5. Write the installation's `.npmrc` with `minimum-release-age=0` so new versions are immediately available. +6. Run managed pnpm with `install --ignore-workspace --frozen-lockfile` in the version directory. Activation waits for a successful install. + +Missing advertised lockfiles and integrity failures stop installation. Older +packages without a companion keep the legacy dependency installation behavior. +When an older upgrade client drops the companion, the new binary's self-setup +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. #### Step 5: Version Swap diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index ea9bb73832..d7532548af 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -221,6 +221,19 @@ platform payload or creates an installation root. The installer replicates the same result as `install.ps1`, implemented in Rust via `vp_setup`. +For binaries that support self-setup, `vp-setup.exe` extracts the verified +platform archive into a temporary directory and delegates permanent installation +to the downloaded binary. The archive bundles `vp-install-lock.yaml` beside +`vp.exe` and `vp-shim.exe`, for both releases and commit previews. Self-setup +retains this companion in the version's `bin` directory and restores it as +`pnpm-lock.yaml` before installing dependencies with `--frozen-lockfile`. + +An advertised but missing companion, a lockfile mismatch, or an integrity failure +stops installation. Older packages without a companion retain their existing +installation behavior. See [Global installation lockfile](./merge-global-and-local-cli.md#global-installation-lockfile) +for publication and recovery details. The phases below describe the installation +result; binaries without self-setup support use the installer's legacy path. + ``` ┌─────────────────────────────────────────────────────────────┐ │ RESOLVE │ @@ -255,16 +268,19 @@ The installer replicates the same result as `install.ps1`, implemented in Rust v ┌─────────────────────────────────────────────────────────────┐ │ INSTALL │ │ │ -│ ┌─ extract binary ──────── \{version}\bin\ │ -│ │ vp.exe + vp-shim.exe │ +│ ┌─ extract package ────── \{version}\bin\ │ +│ │ vp.exe + vp-shim.exe │ +│ │ + vp-install-lock.yaml │ +│ │ │ +│ ├─ generate package.json ─ wrapper with vite-plus dep │ +│ │ pins pnpm@10.34.7 │ │ │ │ -│ ├─ generate package.json ─ wrapper with vite-plus dep │ -│ │ pins pnpm@10.33.0 │ +│ ├─ restore lockfile ────── {version}\pnpm-lock.yaml │ │ │ │ -│ ├─ write .npmrc ────────── minimum-release-age=0 │ +│ ├─ write .npmrc ────────── minimum-release-age=0 │ │ │ │ -│ └─ install deps ────────── spawn: vp install --silent │ -│ installs vite-plus + transitive │ +│ └─ install deps ────────── managed pnpm │ +│ install --frozen-lockfile │ └─────────────────────────────────────────────────────────────┘ │ ▼ @@ -307,23 +323,24 @@ The installer replicates the same result as `install.ps1`, implemented in Rust v Each phase maps to `vp_setup` library functions shared with `vp upgrade`: -| Phase | Key function | Crate | -| ----------------- | ------------------------------------------ | -------------- | -| Resolve | `platform::detect_platform_suffix()` | `vp_setup` | -| Resolve | `install::read_current_version()` | `vp_setup` | -| Resolve | `registry::resolve_version_string()` | `vp_setup` | -| Download & Verify | `registry::resolve_platform_package()` | `vp_setup` | -| Download & Verify | `HttpClient::get_bytes()` | `vp_pm_cli` | -| Download & Verify | `integrity::verify_integrity()` | `vp_setup` | -| Install | `install::extract_platform_package()` | `vp_setup` | -| Install | `install::generate_wrapper_package_json()` | `vp_setup` | -| Install | `install::write_release_age_overrides()` | `vp_setup` | -| Install | `install::install_production_deps()` | `vp_setup` | -| Activate | `install::save_previous_version()` | `vp_setup` | -| Activate | `install::swap_current_link()` | `vp_setup` | -| Activate | `install::cleanup_old_versions()` | `vp_setup` | -| Configure | `install::refresh_shims()` | `vp_setup` | -| Configure | `windows_path::add_to_user_path()` | `vp_installer` | +| Phase | Key function | Crate | +| ----------------- | --------------------------------------------- | -------------- | +| Resolve | `platform::detect_platform_suffix()` | `vp_setup` | +| Resolve | `install::read_current_version()` | `vp_setup` | +| Resolve | `registry::resolve_version_string()` | `vp_setup` | +| Download & Verify | `registry::resolve_platform_package()` | `vp_setup` | +| Download & Verify | `HttpClient::get_bytes()` | `vp_pm_cli` | +| Download & Verify | `integrity::verify_integrity()` | `vp_setup` | +| Install | `install::extract_platform_package()` | `vp_setup` | +| Install | `install::generate_wrapper_package_json()` | `vp_setup` | +| Install | `lockfile::prepare()` / `lockfile::restore()` | `vp_setup` | +| Install | `install::write_release_age_overrides()` | `vp_setup` | +| Install | `install::install_production_deps()` | `vp_setup` | +| Activate | `install::save_previous_version()` | `vp_setup` | +| Activate | `install::swap_current_link()` | `vp_setup` | +| Activate | `install::cleanup_old_versions()` | `vp_setup` | +| Configure | `install::refresh_shims()` | `vp_setup` | +| Configure | `windows_path::add_to_user_path()` | `vp_installer` | **Same-version repair**: When the resolved version matches the installed version, the DOWNLOAD/INSTALL/ACTIVATE phases are skipped entirely (saving 1 HTTP request + all I/O). The CONFIGURE phase always runs to repair shims, env files, and PATH if needed.