diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 5a2c7846d2..3c6cfb9dd2 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -147,32 +147,19 @@ jobs: - name: Pack packages into tgz run: | mkdir -p tmp/tgz - # Every tgz consumer below references fixed `*-0.0.0.tgz` filenames. - # A release commit can leave `packages/{core,cli}` at a published - # version (e.g. 0.1.22), which would make `pnpm pack` emit - # `*-0.1.22.tgz` instead. Pin both to 0.0.0 so the names stay stable. + # patch-project.ts serves these tgz through a local npm registry + # (packages/tools/src/local-npm-registry.ts), so `vp migrate` pins + # and installs them like a real release, with every package manager + # (including bun) resolving the standard + # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin + # both packages to 0.0.0 so a correctly installed local build is + # always distinguishable from any published version + # (verify-install.ts asserts it), even on a release commit that + # leaves packages/{core,cli} at a published version. (cd packages/core && npm pkg set version=0.0.0) (cd packages/cli && npm pkg set version=0.0.0) cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. - # Bun is uniquely strict about peer-dep resolution: - # 1. It checks the *resolved target's* package name and version - # against the peer range (vitest 4.1.9 declares peer - # `vite ^6 || ^7 || ^8`). - # 2. A file: override pointing at the vite-plus-core tgz fails - # both the name check (target is `@voidzero-dev/vite-plus-core`, - # not `vite`) and the version check (0.0.0 is outside `^6|^7|^8`). - # pnpm/npm/yarn don't enforce either, and using the same core tgz as - # the file: target for both `vite` and `@voidzero-dev/vite-plus-core` - # is the only configuration they install cleanly. See - # https://github.com/oven-sh/bun/issues/8406. - # - # Generate a sibling vite-7.99.0.tgz: a copy of the core tgz with - # `package.json#name` rewritten to "vite" and `version` to 7.99.0. - # ecosystem-ci/patch-project.ts points its vite override at this - # tgz only for bun-based projects (e.g. bun-vite-template); pnpm-, - # npm- and yarn-based ecosystem projects use the real core tgz. - pnpm exec tool repack-vite-tgz tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz tmp/tgz/vite-7.99.0.tgz vite 7.99.0 # Copy vp binary for e2e-test job (findVpBinary expects it in target/) cp target/${{ matrix.target }}/release/vp tmp/tgz/vp 2>/dev/null || cp target/${{ matrix.target }}/release/vp.exe tmp/tgz/vp.exe 2>/dev/null || true cp target/${{ matrix.target }}/release/vp-shim.exe tmp/tgz/vp-shim.exe 2>/dev/null || true @@ -476,13 +463,14 @@ jobs: - name: Migrate in ${{ matrix.project.name }} working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} shell: bash - # patch-project.ts runs `vp migrate` and the follow-up `vp install`, - # handling pnpm's minimumReleaseAge gate per project (disabled so - # freshly published deps install, kept off for dify's time-based - # resolution policy). See its comments for the details. + # patch-project.ts starts a local npm registry serving the packed + # local build (it stays up for the later steps; the registry env is + # exported via GITHUB_ENV), then runs `vp migrate` and the follow-up + # `vp install` through it, with pnpm's minimumReleaseAge gate disabled + # so freshly published upstream deps install. See its comments. run: node $GITHUB_WORKSPACE/ecosystem-ci/patch-project.ts ${{ matrix.project.name }} - - name: Verify local tgz packages installed + - name: Verify local packages installed working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} shell: bash run: node $GITHUB_WORKSPACE/ecosystem-ci/verify-install.ts diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 2a2973cc30..aead0738d1 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -95,34 +95,19 @@ jobs: - name: Pack packages into tgz run: | mkdir -p tmp/tgz - # Every tgz consumer below references fixed `*-0.0.0.tgz` filenames. - # A release commit can leave `packages/{core,cli}` at a published - # version (e.g. 0.1.22), which would make `pnpm pack` emit - # `*-0.1.22.tgz` instead. Pin both to 0.0.0 so the names stay stable. + # The test jobs serve these tgz through a local npm registry + # (packages/tools/src/local-npm-registry.ts), so `vp create` pins + # and installs them like a real release, with every package manager + # (including bun) resolving the standard + # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. Pin + # both packages to 0.0.0 so a correctly installed local build is + # always distinguishable from any published version, even on a + # release commit that leaves packages/{core,cli} at a published + # version. (cd packages/core && npm pkg set version=0.0.0) (cd packages/cli && npm pkg set version=0.0.0) cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. cd packages/cli && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. - # Bun is uniquely strict about peer-dep resolution: - # 1. It checks the *resolved target's* package name and version - # against the peer range (vitest 4.1.9 declares peer - # `vite ^6 || ^7 || ^8`). - # 2. A file: override pointing at the vite-plus-core tgz fails - # both the name check (target is `@voidzero-dev/vite-plus-core`, - # not `vite`) and the version check (0.0.0 is outside `^6|^7|^8`). - # pnpm/npm/yarn don't enforce either, and using the same core tgz as - # the file: target for both `vite` and `@voidzero-dev/vite-plus-core` - # is the only configuration they install cleanly. See - # https://github.com/oven-sh/bun/issues/8406. - # - # Generate a sibling vite-7.99.0.tgz: a copy of the core tgz with - # `package.json#name` rewritten to "vite" and `version` to 7.99.0. - # Only the bun matrix entry below points its vite override at this - # alias tgz; pnpm/npm/yarn keep pointing at the real core tgz so - # pnpm's workspace resolver doesn't trip on a "vite@" - # registry lookup (the renamed tgz makes pnpm register the dep as - # vite@7.99.0 and then probe npmjs.org to validate the version). - pnpm exec tool repack-vite-tgz tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz tmp/tgz/vite-7.99.0.tgz vite 7.99.0 # Copy vp binary for test jobs cp target/x86_64-unknown-linux-gnu/release/vp tmp/tgz/vp ls -la tmp/tgz @@ -179,15 +164,12 @@ jobs: - yarn - bun env: - # Bun's strict peer check requires the `vite` override target's tgz to be - # named "vite" with a version satisfying vitest's `peer vite ^6 || ^7 || ^8`. - # The bun matrix entry uses the masquerade tgz (vite-7.99.0.tgz). pnpm/npm/yarn - # point at the real core tgz — anything else trips a registry lookup for - # vite@ when sub-package and override targets are both file: tgz aliases. - VITE_OVERRIDE_TGZ: ${{ matrix.package-manager == 'bun' && 'vite-7.99.0.tgz' || 'voidzero-dev-vite-plus-core-0.0.0.tgz' }} - VP_VERSION: 'file:${{ github.workspace }}/tmp/tgz/vite-plus-0.0.0.tgz' + # The local registry serves the packed 0.0.0 build, so create pins the + # exact version like a real release. The vp binary was built before the + # pack step pinned the package versions, so align the version explicitly. + VP_VERSION: '0.0.0' # Force full dependency rewriting so the library template's existing - # vite-plus dep gets overridden with the local tgz + # vite-plus dep gets overridden with the local build VP_FORCE_MIGRATE: '1' # yarn 4 quarantines packages published within `npmMinimalAgeGate` # (default 1440 min / 24h). When an oxlint bump landed <24h ago, the @@ -221,10 +203,34 @@ jobs: which vp vp --version + - name: Start local npm registry + # Serve the packed local build behind a real registry interface for + # the create's install and every later step (the registry env is + # exported via GITHUB_ENV; the detached server lives for the job). + # Both output fds go to a file, not this step's pipes, so the step + # completes while the server keeps running. + run: | + node "$GITHUB_WORKSPACE/packages/tools/src/local-npm-registry.ts" --serve --packages-dir "$GITHUB_WORKSPACE/tmp/tgz" > "$RUNNER_TEMP/local-registry.out" 2>&1 & + server_pid=$! + until grep -q '"registry"' "$RUNNER_TEMP/local-registry.out" 2>/dev/null; do + if ! kill -0 "$server_pid" 2>/dev/null; then + echo "local registry failed to start:" + cat "$RUNNER_TEMP/local-registry.out" + exit 1 + fi + sleep 0.2 + done + handshake=$(head -1 "$RUNNER_TEMP/local-registry.out") + echo "$handshake" + echo "$handshake" | node -e 'const { env } = JSON.parse(require("node:fs").readFileSync(0, "utf8")); for (const [k, v] of Object.entries(env)) console.log(`${k}=${v}`)' >> "$GITHUB_ENV" + + # No VP_OVERRIDE_PACKAGES: with VP_VERSION=0.0.0 the product default + # override map already pins `vite` to npm:@voidzero-dev/vite-plus-core@0.0.0 + # and `vitest` to the bundled version (see VITE_PLUS_OVERRIDE_PACKAGES in + # packages/cli/src/utils/constants.ts), and stays correct across vitest + # bumps without editing this workflow. - name: Run vp create ${{ matrix.template.name }} with ${{ matrix.package-manager }} working-directory: ${{ runner.temp }} - env: - VP_OVERRIDE_PACKAGES: '{"vite":"file:${{ github.workspace }}/tmp/tgz/${{ env.VITE_OVERRIDE_TGZ }}","@voidzero-dev/vite-plus-core":"file:${{ github.workspace }}/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz","vitest":"4.1.9","@vitest/expect":"4.1.9","@vitest/runner":"4.1.9","@vitest/snapshot":"4.1.9","@vitest/spy":"4.1.9","@vitest/utils":"4.1.9","@vitest/mocker":"4.1.9","@vitest/pretty-format":"4.1.9","@vitest/coverage-v8":"4.1.9","@vitest/coverage-istanbul":"4.1.9"}' run: | vp create ${{ matrix.template.create-args }} \ --no-interactive \ @@ -334,7 +340,7 @@ jobs: fi echo "✓ vite-plus, vite, vitest are all single instances" - - name: Verify local tgz packages installed + - name: Verify local packages installed working-directory: ${{ runner.temp }}/test-project run: | node -e " @@ -459,18 +465,23 @@ jobs: if: matrix.template.name == 'monorepo' working-directory: ${{ runner.temp }}/test-project run: | - # Under npm, `vp run ready` reaches 100% cache hit only on the - # third invocation (#1638): vite-task's directory-listing + # Under npm and yarn, `vp run ready` reaches 100% cache hit only on + # the third invocation (#1638): vite-task's directory-listing # fingerprint and fspy read/write tracking surface false-positive # misses on run #2 because `packages/utils/node_modules/` is born - # during run #1. pnpm/yarn/bun pre-create per-package - # `node_modules/` at install time and reach 100% on run #2. + # during run #1. pnpm and bun pre-create per-package + # `node_modules/` at install time and reach 100% on run #2; Yarn + # Berry did too under the old file: overrides (per-package copies), + # but with registry-resolved semver specs it hoists the workspace + # deps like a real install, so it now behaves like npm here. # The preceding `Verify project builds` step already invoked # `vp run ready` once (verify-command for monorepo), so one - # extra warm-up here is enough under npm. - if [ "${{ matrix.package-manager }}" = "npm" ]; then - vp run ready >/dev/null 2>&1 - fi + # extra warm-up here is enough. + case "${{ matrix.package-manager }}" in + npm|yarn) + vp run ready >/dev/null 2>&1 + ;; + esac output=$(vp run ready 2>&1) echo "$output" if ! echo "$output" | grep -q 'cache hit (100%)'; then diff --git a/AGENTS.md b/AGENTS.md index 8c0d2f40f3..a4576bc511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ vite-plus/ - **Generated project agent guidance**: `packages/cli/AGENTS.md` and `packages/cli/src/utils/agent.ts`; do not edit these when the task is only to improve root repo guidance. - **Product/repo docs**: root contributor docs live at the repo root and the VitePress site under `docs/` (`docs/guide/`, `docs/config/`); generated agent guidance is separate. - **CLI output behavior**: inspect the relevant code plus `packages/cli/snap-tests/` or `packages/cli/snap-tests-global/`. +- **Install-testing against the local build**: `packages/tools/src/local-npm-registry.ts` serves the packed checkout behind a real registry interface; used by install snap fixtures (`localVitePlusPackages`), ecosystem e2e (`ecosystem-ci/patch-project.ts`), and local `vp migrate`/`vp create` iteration (see `CONTRIBUTING.md`). ## Command and Config Model diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21b95bfbf9..918f74cfad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,29 @@ Verify the link with `ls -l node_modules/vite-plus` (it should be a symlink into - `pnpm link` may also add a `packageManager` field to the test project's `package.json`; revert it if unwanted. - Undo with `pnpm unlink vite-plus`, or remove the override and run `pnpm install`. +### Test `vp migrate` / `vp create` through a local npm registry + +`pnpm link` swaps the code inside an existing project, but `vp migrate` and `vp create` pin the exact CLI version and then _install_ it, so the checkout's `vite-plus` / `@voidzero-dev/vite-plus-core` must be resolvable from a registry. `packages/tools/src/local-npm-registry.ts` provides that: it packs the checkout, serves the tarballs behind a real registry HTTP interface, and proxies every other package upstream. This replaces the old pkg.pr.new publish + registry-bridge round-trip for local iteration; you can verify migrate/create logic immediately after a build. + +```bash +pnpm build # the served packages are built artifacts; rebuild after JS changes + +# One-shot: wrap any command (from the project you want to migrate) +cd /path/to/test-project +node /path/to/vite-plus/packages/tools/src/local-npm-registry.ts --pack -- vp migrate --no-interactive +node /path/to/vite-plus/packages/tools/src/local-npm-registry.ts --pack -- vp create vite:application --no-interactive + +# Or keep a server running for repeated commands (from the vite-plus checkout) +pnpm local-registry --pack --serve +# copy the printed `export ...` lines into the shell where you run vp +``` + +Notes: + +- The served versions carry an old publish time, so `minimumReleaseAge` gates never quarantine them, and wrapped runs get throwaway Yarn Berry / bun caches (both cache registry state in ways that would otherwise leak stale local builds between runs). +- The same server backs the install snap fixtures (`localVitePlusPackages` in `steps.json`) and ecosystem e2e (`ecosystem-ci/patch-project.ts`), so a flow that works here works there too. +- `pnpm local-registry:ps` lists any registry processes still running (e.g. a `--serve` you forgot, or a wrapper that was killed mid-run); `pnpm local-registry:kill` stops them all and removes their leftover temp caches. + ### Global CLI (Rust) changes `pnpm link` only swaps the JS side; the `vp` binary on `PATH` (and the Rust-backed commands it handles directly, such as package-manager commands) is still whatever is installed in `~/.vite-plus`. For changes to the Rust global CLI (`crates/`), install it from source, and combine with `pnpm link` when the change spans both layers: diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 41fc8ed688..226f59a212 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -1,9 +1,10 @@ -import { execSync } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; +import { execSync, spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { appendFile, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { VITEST_VERSION } from '../packages/cli/src/utils/constants.ts'; -import { ecosystemCiDir, tgzDir } from './paths.ts'; +import { ecosystemCiDir, tgzDir, vitePlusTgzVersion } from './paths.ts'; import repos from './repo.json' with { type: 'json' }; const projects = Object.keys(repos); @@ -19,12 +20,68 @@ const repoRoot = join(ecosystemCiDir, project); const repoConfig = repos[project as keyof typeof repos]; const directory = 'directory' in repoConfig ? repoConfig.directory : undefined; const cwd = directory ? join(repoRoot, directory) : repoRoot; -// The e2e build job pins packages/cli to 0.0.0 before `pnpm pack`, so the -// artifact is always vite-plus-0.0.0.tgz regardless of the committed version. -const vitePlusTgz = `file:${tgzDir}/vite-plus-0.0.0.tgz`; // run vp migrate const cli = process.env.VP_CLI_BIN ?? 'vp'; +// The packed local build in tmp/tgz is served through a local npm registry +// (local-npm-registry.ts), so vp migrate pins and installs the checkout's +// own version through the standard registry code paths, with no `file:` specs. +const vitePlusVersion = vitePlusTgzVersion(); + +const registryScript = join( + import.meta.dirname, + '..', + 'packages', + 'tools', + 'src', + 'local-npm-registry.ts', +); +// Detach the server so it can outlive this script on CI: the lockfiles +// written below reference its tarball URLs, and later workflow steps (the +// project's own vp commands) inherit the registry env via GITHUB_ENV. +// stderr must not inherit this process's streams: the detached server would +// hold the step's output pipe open after this script exits. +const registryServer = spawn( + process.execPath, + [registryScript, '--serve', '--packages-dir', tgzDir], + { + stdio: ['ignore', 'pipe', 'ignore'], + detached: true, + }, +); +const registryInfo = await new Promise<{ registry: string; env: Record }>( + (resolve, reject) => { + let buffered = ''; + registryServer.stdout.on('data', (chunk: Buffer) => { + buffered += chunk.toString(); + const newline = buffered.indexOf('\n'); + if (newline !== -1) { + resolve(JSON.parse(buffered.slice(0, newline))); + } + }); + registryServer.on('error', reject); + registryServer.on('exit', (code) => reject(new Error(`registry exited early (${code})`))); + }, +); +console.log( + `Serving local Vite+ packages at ${registryInfo.registry} (vite-plus@${vitePlusVersion})`, +); +// The server prints nothing after the handshake; release the pipe and the +// process handle so they don't keep this script's event loop alive after the +// installs below finish. +registryServer.stdout.destroy(); +registryServer.unref(); + +if (process.env.GITHUB_ENV) { + // Keep the registry reachable for the workflow's later steps. + const lines = Object.entries(registryInfo.env) + .map(([key, value]) => `${key}=${value}\n`) + .join(''); + await appendFile(process.env.GITHUB_ENV, lines); +} else { + process.on('exit', () => registryServer.kill()); +} + if (project === 'rollipop') { const oxfmtrc = await readFile(join(repoRoot, '.oxfmtrc.json'), 'utf-8'); await writeFile( @@ -61,11 +118,10 @@ if (project === 'vinext') { } if (project === 'dify') { - // dify sets `minimumReleaseAge` (0) with `resolutionMode: time-based`, and - // pnpm 11.5.2 crashes with ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED - // once the policy machinery is active and the local `file:` tgz overrides - // produce violations (file deps have no publish timestamp). Remove the key - // so the policy stays inactive for the ecosystem run. + // dify sets `minimumReleaseAge` with `resolutionMode: time-based`. Keep the + // policy inactive for the ecosystem run so a same-day upstream publish does + // not fail resolution (the local Vite+ packages themselves carry an old + // `time` from the registry, so they always pass age gates). const workspacePath = join(repoRoot, 'pnpm-workspace.yaml'); const workspace = await readFile(workspacePath, 'utf-8'); const patched = workspace.replace(/^minimumReleaseAge:.*\n/m, ''); @@ -79,16 +135,6 @@ if (project === 'dify') { // vp migrate runs full dependency rewriting instead of skipping. const forceFreshMigration = 'forceFreshMigration' in repoConfig && repoConfig.forceFreshMigration; -// Bun is uniquely strict about vitest's `peer vite ^6 || ^7 || ^8` resolution -// (https://github.com/oven-sh/bun/issues/8406): it checks both the override -// target's package name and version. Point bun-based projects at the -// vite-7.99.0 alias tgz (a copy of core renamed to "vite" with a satisfying -// version); pnpm/npm/yarn must keep pointing at the real core tgz, otherwise -// they trip a registry lookup for "vite@" when a workspace -// sub-package and the override both reference the same vite-named alias. -const isBunProject = project === 'bun-vite-template'; -const viteOverrideTgz = isBunProject ? `vite-7.99.0.tgz` : `voidzero-dev-vite-plus-core-0.0.0.tgz`; - // Mirror VITE_PLUS_OVERRIDE_PACKAGES: pin `vitest` only. The `@vitest/*` family // are exact deps of `vitest`, so a single `vitest` override cascades them. // @@ -106,78 +152,55 @@ const vitestOverrides = { '@vitest/coverage-istanbul': VITEST_VERSION, }; +// E2E intentionally installs just-published toolchain packages (e.g. +// @oxlint/migrate during `vp migrate`, freshly bumped @oxc-project/runtime +// during `vp install`). Disable pnpm's minimumReleaseAge gate so a same-day +// publish does not fail with ERR_PNPM_NO_MATURE_MATCHING_VERSION. pnpm >= 10.6 +// only reads the PNPM_CONFIG_* spelling; older pnpm reads the lowercase form. +// +// Projects with `resolutionMode: time-based` (currently dify) are the +// exception: defining a minimumReleaseAge (even 0, via any env spelling) +// activates pnpm's resolution-policy engine there, which vp's bundled pnpm +// cannot handle (ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, no +// handleResolutionPolicyViolations callback wired). Their `minimumReleaseAge:` +// key is stripped by the per-project patches above, so with no gate env the +// policy stays inactive and installs work. +const workspaceYamlPath = join(repoRoot, 'pnpm-workspace.yaml'); +const timeBasedResolution = + existsSync(workspaceYamlPath) && + /^resolutionMode:\s*time-based/m.test(readFileSync(workspaceYamlPath, 'utf-8')); +const releaseAgeEnv = timeBasedResolution + ? {} + : { + pnpm_config_minimum_release_age: '0', + PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0', + }; + const migrateEnv: NodeJS.ProcessEnv = { ...process.env, + ...registryInfo.env, ...(forceFreshMigration ? { VP_FORCE_MIGRATE: '1' } : {}), VP_OVERRIDE_PACKAGES: JSON.stringify({ - vite: `file:${tgzDir}/${viteOverrideTgz}`, - '@voidzero-dev/vite-plus-core': `file:${tgzDir}/voidzero-dev-vite-plus-core-0.0.0.tgz`, + vite: `npm:@voidzero-dev/vite-plus-core@${vitePlusVersion}`, ...vitestOverrides, }), - VP_VERSION: vitePlusTgz, - // E2E intentionally installs just-published toolchain packages (e.g. - // @oxlint/migrate during `vp migrate`). Disable pnpm's minimumReleaseAge gate - // so a same-day publish does not fail with ERR_PNPM_NO_MATURE_MATCHING_VERSION. - // This is scoped to the migrate subprocess; the workflow's follow-up - // `vp install` runs without it (see the dify note below). - pnpm_config_minimum_release_age: '0', -}; - -const isDify = project === 'dify'; - -try { - execSync(`${cli} migrate --no-agent --no-interactive`, { - cwd, - stdio: 'inherit', - env: migrateEnv, - }); -} catch (err) { - // dify sets `resolutionMode: time-based`, so the gate var above re-activates - // pnpm's resolution policy during migrate's auto-install. vp's bundled pnpm - // has no handleResolutionPolicyViolations callback, so the local `file:` tgz - // overrides (no publish timestamp) crash it with - // ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED. Tolerate it: migrate still - // generated the config and import rewrites, and the workflow's follow-up - // `vp install` (run without the gate var, so the policy stays inactive) does - // the real install. Other projects must still fail hard. - if (!isDify) { - throw err; - } - console.warn( - 'dify: `vp migrate` auto-install failed (expected with the age gate active); ' + - 'continuing, `vp install` will resync node_modules.', - ); -} - -const packageJsonPath = join(cwd, 'package.json'); -const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as { - dependencies?: Record; - devDependencies?: Record; + // The vp binary was built before the pack step pinned the package versions, + // so align the version migrate pins with the tgz the registry serves. + VP_VERSION: vitePlusVersion, + ...releaseAgeEnv, }; -if (packageJson.dependencies?.['vite-plus']) { - packageJson.dependencies['vite-plus'] = vitePlusTgz; -} else { - packageJson.devDependencies ??= {}; - packageJson.devDependencies['vite-plus'] = vitePlusTgz; -} - -await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf-8'); - -// Install with the local tgz overrides now wired into package.json. Disable -// pnpm's minimumReleaseAge gate so the freshly published bumped deps (e.g. -// @oxc-project/runtime, @oxfmt/binding-*) pass `vp install`'s lockfile -// supply-chain check instead of failing with -// ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION. -// -// dify is the exception: it sets `resolutionMode: time-based`, so the gate var -// re-activates pnpm's resolution policy and vp's bundled pnpm (no -// handleResolutionPolicyViolations callback) crashes on the local file: tgz -// overrides with ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED. Its -// `minimumReleaseAge:` key was already removed above, so the policy stays -// inactive when the var is unset. -const installEnv: NodeJS.ProcessEnv = { ...process.env }; -if (!isDify) { - installEnv.pnpm_config_minimum_release_age = '0'; -} -execSync(`${cli} install --no-frozen-lockfile`, { cwd, stdio: 'inherit', env: installEnv }); +execSync(`${cli} migrate --no-agent --no-interactive`, { + cwd, + stdio: 'inherit', + env: migrateEnv, +}); + +// Install through the local registry. `vp migrate` already pinned +// `vite-plus@` in package.json exactly like a real migration, so no +// manual package.json rewrite is needed. +execSync(`${cli} install --no-frozen-lockfile`, { + cwd, + stdio: 'inherit', + env: { ...process.env, ...registryInfo.env, ...releaseAgeEnv }, +}); diff --git a/ecosystem-ci/paths.ts b/ecosystem-ci/paths.ts index 7635d60b55..d6e2c3b118 100644 --- a/ecosystem-ci/paths.ts +++ b/ecosystem-ci/paths.ts @@ -1,3 +1,4 @@ +import { readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,3 +12,19 @@ export const ecosystemCiDir = join(tempBase, 'vite-plus-ecosystem-ci'); // tgz path: always use local tmp/tgz export const tgzDir = join(projectDir, '..', 'tmp', 'tgz'); + +/** + * The version of the packed vite-plus tgz that the local registry serves and + * that `vp migrate` pins: 0.0.0 on CI (the e2e pack step pins it so a local + * build is always distinguishable from a published version), the checkout + * version on a local run. + */ +export function vitePlusTgzVersion(): string { + const version = readdirSync(tgzDir) + .map((entry) => /^vite-plus-(\d.*)\.tgz$/.exec(entry)?.[1]) + .find(Boolean); + if (!version) { + throw new Error(`No vite-plus-.tgz found in ${tgzDir}`); + } + return version; +} diff --git a/ecosystem-ci/verify-install.ts b/ecosystem-ci/verify-install.ts index 212b741122..51a22924f5 100644 --- a/ecosystem-ci/verify-install.ts +++ b/ecosystem-ci/verify-install.ts @@ -2,13 +2,15 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; +import { vitePlusTgzVersion } from './paths.ts'; + const require = createRequire(`${process.cwd()}/`); -// The ecosystem-ci pack step pins packages/cli to 0.0.0 before `pnpm pack`, so -// a correctly installed local build always reports 0.0.0 — never the published -// registry version (which `patch-project.ts` likewise references as a fixed -// `vite-plus-0.0.0.tgz`). -const expectedVersion = '0.0.0'; +// patch-project.ts serves the packed tgz through the local registry, so a +// correctly installed local build always reports the tgz version (0.0.0 on +// CI, where the pack step pins it precisely so a local build is never +// mistaken for a published registry version). +const expectedVersion = vitePlusTgzVersion(); try { const pkgPath = require.resolve('vite-plus/package.json'); @@ -31,11 +33,15 @@ try { const vitePlusSpec = projectPkg.dependencies?.['vite-plus'] ?? projectPkg.devDependencies?.['vite-plus']; - const isFileSpec = vitePlusSpec?.startsWith('file:') ?? false; - const isPnpmFileInstall = pkgPath.includes(`${path.sep}.pnpm${path.sep}vite-plus@file+`); - if (!isFileSpec && !isPnpmFileInstall) { + // The migration must pin the local version as a plain registry spec + // (resolved through the local registry), exactly like a real migration, + // not a file:/link: escape hatch. pnpm workspace projects reference the + // pinned version through the catalog instead of an inline version; the + // installed `pkg.version` assertion above already proves which version the + // catalog resolved to. + if (vitePlusSpec !== expectedVersion && !vitePlusSpec?.startsWith('catalog:')) { console.error( - `x vite-plus: expected local file: install, got spec ${vitePlusSpec ?? ''}`, + `x vite-plus: expected exact registry spec ${expectedVersion} (or a catalog reference), got ${vitePlusSpec ?? ''}`, ); console.error(` resolved to ${pkgPath}`); process.exit(1); @@ -55,7 +61,7 @@ try { process.exit(1); } - console.log(`ok vite-plus@${pkg.version} (${vitePlusSpec ?? 'unknown spec'})`); + console.log(`ok vite-plus@${pkg.version} (${vitePlusSpec})`); console.log(`ok oxlint@${oxlintPkg.version} from vite-plus dependency tree`); } catch (error) { console.error('x vite-plus: not installed or incomplete'); diff --git a/package.json b/package.json index 2d45026b19..2777dac197 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "bootstrap-cli": "pnpm build && cargo build -p vite_global_cli -p vite_trampoline --release && pnpm install-global-cli", "bootstrap-cli:ci": "pnpm install-global-cli", "install-global-cli": "tool install-global-cli", + "local-registry": "node packages/tools/src/local-npm-registry.ts", + "local-registry:ps": "node packages/tools/src/local-npm-registry.ts --ps", + "local-registry:kill": "node packages/tools/src/local-npm-registry.ts --kill", "tsgo": "tsgo -b tsconfig.json", "lint": "vp lint --type-aware --type-check --threads 4", "test": "vp test run && pnpm -r snap-test", diff --git a/packages/cli/snap-tests-global/create-framework-shim-astro/snap.txt b/packages/cli/snap-tests-global/create-framework-shim-astro/snap.txt index d941e5adcb..7ce95c3bb6 100644 --- a/packages/cli/snap-tests-global/create-framework-shim-astro/snap.txt +++ b/packages/cli/snap-tests-global/create-framework-shim-astro/snap.txt @@ -1,8 +1,8 @@ -> vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app +> node $SNAP_LOCAL_REGISTRY -- vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app > cat my-astro-app/src/env.d.ts # check Astro shim /// -> cd my-astro-app && vp install --ignore-scripts -- --no-frozen-lockfile # install dependencies +> cd my-astro-app && node $SNAP_LOCAL_REGISTRY -- vp install --ignore-scripts -- --no-frozen-lockfile # install dependencies > cd my-astro-app && sed -i.bak -e '/jsPlugins/d' -e '/rules:/d' -e '/options:/d' vite.config.ts && vp check --fix # fix generated formatting and ensure no errors pass: Formatting completed for checked files (ms) pass: Found no warnings or lint errors in 6 files (ms, threads) diff --git a/packages/cli/snap-tests-global/create-framework-shim-astro/steps.json b/packages/cli/snap-tests-global/create-framework-shim-astro/steps.json index 21fc6de5fe..139581f90e 100644 --- a/packages/cli/snap-tests-global/create-framework-shim-astro/steps.json +++ b/packages/cli/snap-tests-global/create-framework-shim-astro/steps.json @@ -1,16 +1,14 @@ { "ignoredPlatforms": ["win32"], - "env": { - "VP_VERSION": "0.2.1" - }, + "localVitePlusPackages": true, "commands": [ { - "command": "vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app", + "command": "node $SNAP_LOCAL_REGISTRY -- vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app", "ignoreOutput": true }, "cat my-astro-app/src/env.d.ts # check Astro shim", { - "command": "cd my-astro-app && vp install --ignore-scripts -- --no-frozen-lockfile # install dependencies", + "command": "cd my-astro-app && node $SNAP_LOCAL_REGISTRY -- vp install --ignore-scripts -- --no-frozen-lockfile # install dependencies", "ignoreOutput": true }, { diff --git a/packages/cli/snap-tests-global/create-framework-shim-vue/snap.txt b/packages/cli/snap-tests-global/create-framework-shim-vue/snap.txt index 4fbc187dac..70c986ad18 100644 --- a/packages/cli/snap-tests-global/create-framework-shim-vue/snap.txt +++ b/packages/cli/snap-tests-global/create-framework-shim-vue/snap.txt @@ -1,4 +1,4 @@ -> vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app +> node $SNAP_LOCAL_REGISTRY -- vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app > cat vite-plus-application/src/env.d.ts # check Vue shim was added declare module '*.vue' { import type { DefineComponent } from 'vue'; @@ -6,7 +6,7 @@ declare module '*.vue' { export default component; } -> cd vite-plus-application && vp install # install dependencies +> cd vite-plus-application && node $SNAP_LOCAL_REGISTRY -- vp install # install dependencies > cd vite-plus-application && vp check --fix # fix generated formatting and ensure no errors pass: Formatting completed for checked files (ms) pass: Found no warnings, lint errors, or type errors in 5 files (ms, threads) diff --git a/packages/cli/snap-tests-global/create-framework-shim-vue/steps.json b/packages/cli/snap-tests-global/create-framework-shim-vue/steps.json index 96db535203..372eac2930 100644 --- a/packages/cli/snap-tests-global/create-framework-shim-vue/steps.json +++ b/packages/cli/snap-tests-global/create-framework-shim-vue/steps.json @@ -1,17 +1,21 @@ { - "ignoredPlatforms": ["win32", { "os": "linux", "libc": "musl" }], + "ignoredPlatforms": [ + "win32", + { + "os": "linux", + "libc": "musl" + } + ], "linkCheckoutPackages": true, - "env": { - "VP_VERSION": "0.2.1" - }, + "localVitePlusPackages": true, "commands": [ { - "command": "vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app", + "command": "node $SNAP_LOCAL_REGISTRY -- vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app", "ignoreOutput": true }, "cat vite-plus-application/src/env.d.ts # check Vue shim was added", { - "command": "cd vite-plus-application && vp install # install dependencies", + "command": "cd vite-plus-application && node $SNAP_LOCAL_REGISTRY -- vp install # install dependencies", "ignoreOutput": true }, "cd vite-plus-application && vp check --fix # fix generated formatting and ensure no errors" diff --git a/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt index 84a23c5558..f6f161fd28 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt +++ b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims +> node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims Formatting code... diff --git a/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/steps.json b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/steps.json index c2c7de2e4c..25d33604c1 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/steps.json +++ b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/steps.json @@ -1,13 +1,13 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VITE_DISABLE_AUTO_INSTALL": "1", "CI": "", "VP_SKIP_INSTALL": "" }, "commands": [ - "vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims", + "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims", "cat src/env.d.ts # check both shims were written" ] } diff --git a/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt b/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt index 0a9ee981af..79cb7c1557 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt +++ b/packages/cli/snap-tests-global/migration-framework-shim-astro/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected +> node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected Formatting code... diff --git a/packages/cli/snap-tests-global/migration-framework-shim-astro/steps.json b/packages/cli/snap-tests-global/migration-framework-shim-astro/steps.json index ac209481d4..c67ec065ce 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-astro/steps.json +++ b/packages/cli/snap-tests-global/migration-framework-shim-astro/steps.json @@ -1,13 +1,13 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VITE_DISABLE_AUTO_INSTALL": "1", "CI": "", "VP_SKIP_INSTALL": "" }, "commands": [ - "vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected", + "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected", "cat src/env.d.ts # check Astro shim was written" ] } diff --git a/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt b/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt index 12d1c10233..d55d3f27a9 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt +++ b/packages/cli/snap-tests-global/migration-framework-shim-vue/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected +> node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected Formatting code... diff --git a/packages/cli/snap-tests-global/migration-framework-shim-vue/steps.json b/packages/cli/snap-tests-global/migration-framework-shim-vue/steps.json index 5232d62dfe..26b39118f8 100644 --- a/packages/cli/snap-tests-global/migration-framework-shim-vue/steps.json +++ b/packages/cli/snap-tests-global/migration-framework-shim-vue/steps.json @@ -1,13 +1,13 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VITE_DISABLE_AUTO_INSTALL": "1", "CI": "", "VP_SKIP_INSTALL": "" }, "commands": [ - "vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected", + "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected", "cat src/env.d.ts # check Vue shim was written" ] } diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt index 9629ef6072..0625a707fc 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-npm/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile +> node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile Formatting code... diff --git a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json index 02fb74c380..d82e6c6a27 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json @@ -1,13 +1,13 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VITE_DISABLE_AUTO_INSTALL": "1", "CI": "", "VP_SKIP_INSTALL": "" }, "commands": [ - "vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", + "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", "cat package.json # check package.json has overrides field (not pnpm.overrides)", "node -e \"const lock = require('./package-lock.json'); const vite = lock.packages['node_modules/vite']; if (vite && (vite.name === '@voidzero-dev/vite-plus-core' || vite.resolved?.includes('/@voidzero-dev/vite-plus-core/'))) console.log('lockfile has vite override'); else { console.error('vite override not found in lockfile'); process.exit(1); }\" # verify lockfile updated with override" ] diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt index 972a0f37b3..152010ef13 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/snap.txt @@ -1,4 +1,4 @@ -> vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml +> node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml Formatting code... diff --git a/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json b/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json index 7cb5ba99c0..28072d2673 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json @@ -1,13 +1,13 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VITE_DISABLE_AUTO_INSTALL": "1", "CI": "", "VP_SKIP_INSTALL": "" }, "commands": [ - "vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml", + "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml", "cat package.json # check package.json has no pnpm section", "cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides, peerDependencyRules, and catalog" ] diff --git a/packages/cli/snap-tests/.shared/mock-npm-registry.mjs b/packages/cli/snap-tests/.shared/mock-npm-registry.mjs deleted file mode 100644 index 7fffd0fa5f..0000000000 --- a/packages/cli/snap-tests/.shared/mock-npm-registry.mjs +++ /dev/null @@ -1,121 +0,0 @@ -// Minimal mock npm registry used by `create-org-*` snap-tests. -// -// Reads `./mock-manifest.json` (keyed by URL path, e.g. `"@your-org/create"`) and -// optionally serves `.tgz` tarballs from `./tarballs/`. Picks an -// ephemeral port, sets `NPM_CONFIG_REGISTRY` on the child environment, spawns -// the wrapped command, and tears down when the child exits. -// -// Usage: node mock-server.mjs -- [args...] - -import { spawn } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { createServer } from 'node:http'; -import { get as httpsGet } from 'node:https'; -import path from 'node:path'; - -const manifest = JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')); -const UPSTREAM_REGISTRY = 'https://registry.npmjs.org'; - -function rewriteRegistry(value, registry) { - return JSON.parse(JSON.stringify(value).replaceAll('{REGISTRY}', registry)); -} - -// Stream the upstream response byte-for-byte. Unlike `fetch`, `https` does not -// auto-decompress, so the tarball reaches the client exactly as the registry -// served it (content-encoding and all). bun verifies tarball integrity and -// rejects any re-encoded body, so faithful streaming is what lets bun installs -// work through the proxy (pnpm fetches tarballs from the upstream URL directly, -// so it was never affected). -function proxyToUpstream(req, res) { - // Stream errors can fire after headers are already sent (e.g. the upstream - // connection resets mid-tarball), so guard against a second writeHead. - const fail = (error) => { - if (!res.headersSent) { - res.writeHead(502); - } - res.end(`proxy error: ${error.message}`); - }; - const fetchUrl = (url, redirectsLeft) => { - httpsGet(url, { headers: { accept: req.headers.accept ?? 'application/json' } }, (upstream) => { - const status = upstream.statusCode ?? 502; - if (status >= 300 && status < 400 && upstream.headers.location && redirectsLeft > 0) { - // Draining can still emit 'error' (e.g. the socket resets mid-redirect), - // so guard it here too — otherwise it's uncaught and crashes the server. - upstream.on('error', fail); - upstream.resume(); - fetchUrl(new URL(upstream.headers.location, url).toString(), redirectsLeft - 1); - return; - } - const headers = {}; - // Forward `location` too, so a 3xx we stop following (or one with no - // location to follow) still reaches the client with its redirect target. - for (const name of ['content-type', 'content-encoding', 'content-length', 'location']) { - if (upstream.headers[name] !== undefined) { - headers[name] = upstream.headers[name]; - } - } - // Default a missing content-type (parity with the prior fetch-based proxy) - // so clients that key off it still recognize a proxied tarball. - headers['content-type'] ??= 'application/octet-stream'; - res.writeHead(status, headers); - // `pipe` does not forward source errors, so listen on the response stream - // directly; otherwise a mid-stream upstream error is uncaught and crashes - // the mock server. - upstream.on('error', fail); - upstream.pipe(res); - }).on('error', fail); - }; - fetchUrl(`${UPSTREAM_REGISTRY}${req.url ?? '/'}`, 5); -} - -const server = createServer(async (req, res) => { - const key = decodeURIComponent(req.url ?? '/').replace(/^\/+/, ''); - if (Object.hasOwn(manifest, key)) { - const address = server.address(); - const registry = - address && typeof address !== 'string' ? `http://127.0.0.1:${address.port}` : ''; - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify(rewriteRegistry(manifest[key], registry))); - return; - } - const tarMatch = key.match(/\/-\/([^/]+\.tgz)$/); - if (tarMatch) { - try { - const bytes = readFileSync(path.resolve('./tarballs', tarMatch[1])); - res.writeHead(200, { 'content-type': 'application/octet-stream' }); - res.end(bytes); - return; - } catch { - // fall through to proxy - } - } - // Proxy anything we don't mock (pnpm/latest, tarball downloads for real - // packages, etc.) to the upstream registry. Keeps the fixture scoped to - // just the @org/create manifest while letting vp's other startup work - // (package-manager download) succeed normally. - await proxyToUpstream(req, res); -}); - -server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - console.error('mock-server: failed to bind'); - process.exit(1); - } - const registry = `http://127.0.0.1:${address.port}`; - const separatorIndex = process.argv.indexOf('--'); - if (separatorIndex === -1) { - console.error('usage: node mock-server.mjs -- [args...]'); - server.close(() => process.exit(2)); - return; - } - const [cmd, ...args] = process.argv.slice(separatorIndex + 1); - const child = spawn(cmd, args, { - env: { ...process.env, NPM_CONFIG_REGISTRY: registry }, - stdio: 'inherit', - }); - child.on('exit', (code, signal) => { - const exitCode = code ?? (signal ? 128 : 0); - server.close(() => process.exit(exitCode)); - }); -}); diff --git a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt index 0a0534d8ea..d0d58a4564 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-bun/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager bun --directory approved-app # --approve-builds runs `bun pm trust` for the gated build script (core-js) +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager bun --directory approved-app # --approve-builds runs `bun pm trust` for the gated build script (core-js) ◇ Scaffolded approved-app • Node bun ✓ Dependencies installed in ms @@ -34,7 +34,7 @@ ] } -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --package-manager bun --directory default-app # default run surfaces the gated build with guidance, leaving it untrusted +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --package-manager bun --directory default-app # default run surfaces the gated build with guidance, leaving it untrusted Build scripts were not run for: core-js. @@ -71,7 +71,7 @@ These dependencies may not work until built. Run vp pm approve-builds core-js in } } -> cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build +> cd default-app && node $SNAP_LOCAL_REGISTRY -- vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build bun pm trust v () ./node_modules/core-js @ diff --git a/packages/cli/snap-tests/create-approve-builds-bun/steps.json b/packages/cli/snap-tests/create-approve-builds-bun/steps.json index 9cd5e46825..c90eb5507d 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-bun/steps.json @@ -1,17 +1,17 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VP_SKIP_INSTALL": "", "CI": "", "ADBLOCK": "1" }, "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager bun --directory approved-app # --approve-builds runs `bun pm trust` for the gated build script (core-js)", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager bun --directory approved-app # --approve-builds runs `bun pm trust` for the gated build script (core-js)", "cat approved-app/package.json # core-js recorded under trustedDependencies", - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --package-manager bun --directory default-app # default run surfaces the gated build with guidance, leaving it untrusted", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --package-manager bun --directory default-app # default run surfaces the gated build with guidance, leaving it untrusted", "cat default-app/package.json # no trustedDependencies, the build was not run", - "cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build", + "cd default-app && node $SNAP_LOCAL_REGISTRY -- vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build", "cat default-app/package.json # core-js is now recorded under trustedDependencies" ] } diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt index 49097fcdb8..4e555467a8 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # template ships Prettier, so create installs+migrates before the main install; the gated build (core-js) must still be surfaced and approved +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # template ships Prettier, so create installs+migrates before the main install; the gated build (core-js) must still be surfaced and approved Prettier detected in workspace packages but no root config found. Package-level Prettier must be migrated manually. ◇ Scaffolded approved-app @@ -20,7 +20,7 @@ peerDependencyRules: allowedVersions: vite: "*" -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved Prettier detected in workspace packages but no root config found. Package-level Prettier must be migrated manually. @@ -46,7 +46,7 @@ peerDependencyRules: allowedVersions: vite: "*" -> cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build +> cd default-app && node $SNAP_LOCAL_REGISTRY -- vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" .../core-js@/node_modules/core-js postinstall: Done diff --git a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/steps.json b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/steps.json index 2baf22187b..683e41d6a6 100644 --- a/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/steps.json @@ -1,16 +1,16 @@ { + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VP_SKIP_INSTALL": "", "CI": "", "ADBLOCK": "1" }, "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # template ships Prettier, so create installs+migrates before the main install; the gated build (core-js) must still be surfaced and approved", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # template ships Prettier, so create installs+migrates before the main install; the gated build (core-js) must still be surfaced and approved", "cat approved-app/pnpm-workspace.yaml # approval recorded under allowBuilds despite the migration pre-install", - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved", "cat default-app/pnpm-workspace.yaml # no allowBuilds, the build was not run", - "cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build", + "cd default-app && node $SNAP_LOCAL_REGISTRY -- vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build", "cat default-app/pnpm-workspace.yaml # core-js is now allowed under allowBuilds" ] } diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt index 0596ddd334..031c0d3dab 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # --approve-builds auto-approves and runs the gated build script (core-js) +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # --approve-builds auto-approves and runs the gated build script (core-js) ◇ Scaffolded approved-app • Node pnpm ✓ Dependencies installed in ms @@ -18,7 +18,7 @@ peerDependencyRules: allowedVersions: vite: "*" -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved Build scripts were not run for: core-js. @@ -42,7 +42,7 @@ peerDependencyRules: allowedVersions: vite: "*" -> cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build +> cd default-app && node $SNAP_LOCAL_REGISTRY -- vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build .../core-js@/node_modules/core-js postinstall$ node -e "try{require('./postinstall')}catch(e){}" .../core-js@/node_modules/core-js postinstall: Done diff --git a/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json b/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json index 13d9236f3f..9aa6cad5ee 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json @@ -1,16 +1,16 @@ { + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VP_SKIP_INSTALL": "", "CI": "", "ADBLOCK": "1" }, "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # --approve-builds auto-approves and runs the gated build script (core-js)", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --directory approved-app # --approve-builds auto-approves and runs the gated build script (core-js)", "cat approved-app/pnpm-workspace.yaml # approval recorded under allowBuilds", - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --directory default-app # default run surfaces the gated build with guidance, leaving it unapproved", "cat default-app/pnpm-workspace.yaml # no allowBuilds, the build was not run", - "cd default-app && vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build", + "cd default-app && node $SNAP_LOCAL_REGISTRY -- vp pm approve-builds core-js # the guidance's `vp pm approve-builds` command approves the gated build", "cat default-app/pnpm-workspace.yaml # core-js is now allowed under allowBuilds" ] } diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt index 497b533e1c..33fee78005 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt +++ b/packages/cli/snap-tests/create-approve-builds-yarn/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager yarn --directory approved-app # yarn (Berry) blocks build scripts by default, so --approve-builds enables the gated build (core-js) via dependenciesMeta.built and reinstalls +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager yarn --directory approved-app # yarn (Berry) blocks build scripts by default, so --approve-builds enables the gated build (core-js) via dependenciesMeta.built and reinstalls ◇ Scaffolded approved-app • Node yarn ✓ Dependencies installed in ms @@ -35,7 +35,7 @@ } } -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --package-manager yarn --directory default-app # default run surfaces the gated build with guidance, leaving it disabled +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --package-manager yarn --directory default-app # default run surfaces the gated build with guidance, leaving it disabled Build scripts were not run for: core-js. diff --git a/packages/cli/snap-tests/create-approve-builds-yarn/steps.json b/packages/cli/snap-tests/create-approve-builds-yarn/steps.json index bb33407ffe..f8374a4df7 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-yarn/steps.json @@ -1,14 +1,14 @@ { + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VP_SKIP_INSTALL": "", "CI": "", "ADBLOCK": "1" }, "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager yarn --directory approved-app # yarn (Berry) blocks build scripts by default, so --approve-builds enables the gated build (core-js) via dependenciesMeta.built and reinstalls", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --approve-builds --package-manager yarn --directory approved-app # yarn (Berry) blocks build scripts by default, so --approve-builds enables the gated build (core-js) via dependenciesMeta.built and reinstalls", "cat approved-app/package.json # core-js recorded under dependenciesMeta.built", - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:with-build-dep --no-interactive --package-manager yarn --directory default-app # default run surfaces the gated build with guidance, leaving it disabled", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:with-build-dep --no-interactive --package-manager yarn --directory default-app # default run surfaces the gated build with guidance, leaving it disabled", "cat default-app/package.json # no dependenciesMeta, the build was not run" ] } diff --git a/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt b/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt index 2586a65241..a6e28beec1 100644 --- a/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-dotfiles/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template with _gitignore/_npmrc +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template with _gitignore/_npmrc ◇ Scaffolded my-demo-app • Node pnpm → Next: cd my-demo-app && vp run diff --git a/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json b/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json index 7be80d20e2..713798b97b 100644 --- a/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json +++ b/packages/cli/snap-tests/create-org-bundled-dotfiles/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template with _gitignore/_npmrc", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template with _gitignore/_npmrc", "ls -A my-demo-app # verify _gitignore/_npmrc were renamed and no underscore variants remain", "cat my-demo-app/.gitignore # verify _gitignore content was preserved", "cat my-demo-app/.npmrc # verify _npmrc content was preserved" diff --git a/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt b/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt index caab4b616e..6da5d4f36c 100644 --- a/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt @@ -1,3 +1,3 @@ -[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # `../outside` path rejected at schema-validation, before any tarball fetch +[1]> node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # `../outside` path rejected at schema-validation, before any tarball fetch @your-org/create: createConfig.templates[0].template escapes the package root: ../outside diff --git a/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json b/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json index 5a5f63bd1a..69e70731f1 100644 --- a/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json +++ b/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json @@ -1,5 +1,5 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # `../outside` path rejected at schema-validation, before any tarball fetch" + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # `../outside` path rejected at schema-validation, before any tarball fetch" ] } diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt index b256a7ba2b..801dff255d 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:workspace --no-interactive --directory my-mono --git # bundled monorepo: extract tarball, scaffold, inject create.defaultTemplate +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:workspace --no-interactive --directory my-mono --git # bundled monorepo: extract tarball, scaffold, inject create.defaultTemplate ◇ Scaffolded my-mono • Node pnpm → Next: cd my-mono && vp run diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json b/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json index 227323fc92..84e26a0f47 100644 --- a/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json @@ -1,7 +1,7 @@ { "ignoredPlatforms": [{ "os": "linux", "libc": "musl" }], "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:workspace --no-interactive --directory my-mono --git # bundled monorepo: extract tarball, scaffold, inject create.defaultTemplate", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:workspace --no-interactive --directory my-mono --git # bundled monorepo: extract tarball, scaffold, inject create.defaultTemplate", "cat my-mono/vite.config.ts # create.defaultTemplate auto-set to @your-org", "cat my-mono/pnpm-workspace.yaml # workspace markers preserved", "test -d my-mono/.git && echo 'Git initialized' # git-init prompt covers bundled monorepo path", diff --git a/packages/cli/snap-tests/create-org-bundled/snap.txt b/packages/cli/snap-tests/create-org-bundled/snap.txt index 823a373320..748ed0533f 100644 --- a/packages/cli/snap-tests/create-org-bundled/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template: extract tarball, copy subdir +> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template: extract tarball, copy subdir ◇ Scaffolded my-demo-app • Node pnpm → Next: cd my-demo-app && vp run diff --git a/packages/cli/snap-tests/create-org-bundled/steps.json b/packages/cli/snap-tests/create-org-bundled/steps.json index 0c8f9babe1..fb38d82a21 100644 --- a/packages/cli/snap-tests/create-org-bundled/steps.json +++ b/packages/cli/snap-tests/create-org-bundled/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template: extract tarball, copy subdir", + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template: extract tarball, copy subdir", "cat my-demo-app/package.json # verify package.json name was rewritten", "cat my-demo-app/src/index.ts # verify bundled source copied", "ls my-demo-app/README.md # verify README copied" diff --git a/packages/cli/snap-tests/create-org-config-default/snap.txt b/packages/cli/snap-tests/create-org-config-default/snap.txt index 76b3970a87..e63a4a9cb9 100644 --- a/packages/cli/snap-tests/create-org-config-default/snap.txt +++ b/packages/cli/snap-tests/create-org-config-default/snap.txt @@ -1,4 +1,4 @@ -[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create --no-interactive # bare vp create picks up create.defaultTemplate from vite.config.ts +[1]> node $SNAP_LOCAL_REGISTRY -- vp create --no-interactive # bare vp create picks up create.defaultTemplate from vite.config.ts A template name is required when running `vp create @your-org` in non-interactive mode. diff --git a/packages/cli/snap-tests/create-org-config-default/steps.json b/packages/cli/snap-tests/create-org-config-default/steps.json index 96d50da95f..fa7437cdd0 100644 --- a/packages/cli/snap-tests/create-org-config-default/steps.json +++ b/packages/cli/snap-tests/create-org-config-default/steps.json @@ -1,5 +1,5 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create --no-interactive # bare vp create picks up create.defaultTemplate from vite.config.ts" + "node $SNAP_LOCAL_REGISTRY -- vp create --no-interactive # bare vp create picks up create.defaultTemplate from vite.config.ts" ] } diff --git a/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt b/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt index 6cee8fa983..24db6b6ce3 100644 --- a/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt +++ b/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt @@ -1,3 +1,3 @@ -[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # invalid manifest -> schema error +[1]> node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # invalid manifest -> schema error @your-org/create: createConfig.templates[0].template must be a non-empty string diff --git a/packages/cli/snap-tests/create-org-invalid-manifest/steps.json b/packages/cli/snap-tests/create-org-invalid-manifest/steps.json index b1c5f62be4..0020013a8f 100644 --- a/packages/cli/snap-tests/create-org-invalid-manifest/steps.json +++ b/packages/cli/snap-tests/create-org-invalid-manifest/steps.json @@ -1,5 +1,5 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # invalid manifest -> schema error" + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # invalid manifest -> schema error" ] } diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt index 0d0243b201..57899db355 100644 --- a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt @@ -1,4 +1,4 @@ -[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:monorepo --no-interactive # direct-selecting a monorepo entry inside a monorepo -> refuse +[1]> node $SNAP_LOCAL_REGISTRY -- vp create @your-org:monorepo --no-interactive # direct-selecting a monorepo entry inside a monorepo -> refuse You are already in a monorepo workspace. Use a different template or run this command outside the monorepo diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json index a1c18156dc..e6d8cd3835 100644 --- a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json @@ -1,5 +1,5 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:monorepo --no-interactive # direct-selecting a monorepo entry inside a monorepo -> refuse" + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org:monorepo --no-interactive # direct-selecting a monorepo entry inside a monorepo -> refuse" ] } diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt b/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt index 72c7991362..7add0f62b8 100644 --- a/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt +++ b/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt @@ -1,4 +1,4 @@ -[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # inside monorepo, hides monorepo:true entries + shows omitted footer +[1]> node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # inside monorepo, hides monorepo:true entries + shows omitted footer A template name is required when running `vp create @your-org` in non-interactive mode. diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/steps.json b/packages/cli/snap-tests/create-org-monorepo-filter/steps.json index 482bcedd8f..7b40fdcf91 100644 --- a/packages/cli/snap-tests/create-org-monorepo-filter/steps.json +++ b/packages/cli/snap-tests/create-org-monorepo-filter/steps.json @@ -1,5 +1,5 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # inside monorepo, hides monorepo:true entries + shows omitted footer" + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # inside monorepo, hides monorepo:true entries + shows omitted footer" ] } diff --git a/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt b/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt index 65e01c911e..186903527b 100644 --- a/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt +++ b/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt @@ -1,4 +1,4 @@ -[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # prints manifest table, exits 1 +[1]> node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # prints manifest table, exits 1 A template name is required when running `vp create @your-org` in non-interactive mode. diff --git a/packages/cli/snap-tests/create-org-no-interactive-error/steps.json b/packages/cli/snap-tests/create-org-no-interactive-error/steps.json index f04dd8d5e6..205245cfea 100644 --- a/packages/cli/snap-tests/create-org-no-interactive-error/steps.json +++ b/packages/cli/snap-tests/create-org-no-interactive-error/steps.json @@ -1,5 +1,5 @@ { "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # prints manifest table, exits 1" + "node $SNAP_LOCAL_REGISTRY -- vp create @your-org --no-interactive # prints manifest table, exits 1" ] } diff --git a/packages/cli/snap-tests/migration-standalone-bun-install/snap.txt b/packages/cli/snap-tests/migration-standalone-bun-install/snap.txt index 4af3fba391..513b1f2f2d 100644 --- a/packages/cli/snap-tests/migration-standalone-bun-install/snap.txt +++ b/packages/cli/snap-tests/migration-standalone-bun-install/snap.txt @@ -1,4 +1,4 @@ -> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # standalone (non-workspace) bun upgrade must keep concrete specs so `bun install` resolves instead of failing with `vite@catalog: failed to resolve` +> node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # standalone (non-workspace) bun upgrade must keep concrete specs so `bun install` resolves instead of failing with `vite@catalog: failed to resolve` Formatting code... diff --git a/packages/cli/snap-tests/migration-standalone-bun-install/steps.json b/packages/cli/snap-tests/migration-standalone-bun-install/steps.json index 9dbfb61f76..da0bc482eb 100644 --- a/packages/cli/snap-tests/migration-standalone-bun-install/steps.json +++ b/packages/cli/snap-tests/migration-standalone-bun-install/steps.json @@ -1,12 +1,12 @@ { "ignoredPlatforms": ["win32"], + "localVitePlusPackages": true, "env": { - "VP_VERSION": "0.2.1", "VP_SKIP_INSTALL": "", "CI": "" }, "commands": [ - "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # standalone (non-workspace) bun upgrade must keep concrete specs so `bun install` resolves instead of failing with `vite@catalog: failed to resolve`", + "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # standalone (non-workspace) bun upgrade must keep concrete specs so `bun install` resolves instead of failing with `vite@catalog: failed to resolve`", "cat package.json # vite/vite-plus stay concrete (vite via the @voidzero-dev/vite-plus-core alias); NO top-level catalog field is written" ] } diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index 6b86ed125a..1ab9aa861e 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -147,9 +147,10 @@ export const VITEST_BROWSER_DEP_NAMES = [ // untouched (those are direct-usage signals handled elsewhere). // // The removal only applies when `vitest` is a key vite-plus actually manages in -// the active override config. In force-override / CI mode (`VP_OVERRIDE_PACKAGES` -// with file: tgz aliases) `vitest` is NOT in the override set, so a `vitest` -// entry there is the user's own and must be left untouched. +// the active override config. When a caller-supplied `VP_OVERRIDE_PACKAGES` +// omits the `vitest` key (see the migration-vitest-unmanaged-override snap +// test), `vitest` is NOT managed, so a `vitest` entry there is the user's own +// and must be left untouched. export const VITEST_IS_MANAGED_OVERRIDE = 'vitest' in VITE_PLUS_OVERRIDE_PACKAGES; // Fallback specs used when normalizing a stale wrapper alias. Real user diff --git a/packages/tools/package.json b/packages/tools/package.json index f9c7ae6d02..a7751d531e 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -12,8 +12,7 @@ }, "dependencies": { "@yarnpkg/fslib": "catalog:", - "@yarnpkg/shell": "catalog:", - "nanotar": "catalog:" + "@yarnpkg/shell": "catalog:" }, "devDependencies": { "@oxc-node/cli": "catalog:", diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index d8847ff5fa..3f77e19529 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -29,14 +29,22 @@ switch (subcommand) { const { brandVite } = await import('./brand-vite.ts'); brandVite(); break; - case 'repack-vite-tgz': - const { repackViteTgz } = await import('./repack-vite-tgz.ts'); - await repackViteTgz(); + case 'local-npm-registry': + // Spawn the script by path instead of importing it, so the child carries + // the canonical `node .../local-npm-registry.ts` command line that the + // script's own --ps/--kill maintenance matches. + const { spawnSync } = await import('node:child_process'); + const { fileURLToPath } = await import('node:url'); + const registryScript = fileURLToPath(new URL('./local-npm-registry.ts', import.meta.url)); + const result = spawnSync(process.execPath, [registryScript, ...process.argv.slice(3)], { + stdio: 'inherit', + }); + process.exit(result.status ?? 1); break; default: console.error(`Unknown subcommand: ${subcommand}`); console.error( - 'Available subcommands: snap-test, replace-file-content, sync-remote, json-sort, merge-peer-deps, install-global-cli, brand-vite, repack-vite-tgz', + 'Available subcommands: snap-test, replace-file-content, sync-remote, json-sort, merge-peer-deps, install-global-cli, brand-vite, local-npm-registry', ); process.exit(1); } diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts new file mode 100644 index 0000000000..d369f95dc8 --- /dev/null +++ b/packages/tools/src/local-npm-registry.ts @@ -0,0 +1,570 @@ +// Local npm registry for testing Vite+ installs against the checkout. +// +// Serves locally packed packages (vite-plus, @voidzero-dev/vite-plus-core, +// or any other tgz) as single-version packuments behind a real registry HTTP +// interface, and proxies everything else upstream. Package managers then +// install the checkout's own version even when it is not published on npm, +// with no `file:` specs, pkg.pr.new publish, or registry-bridge round-trip. +// +// Runs directly with `node` (erasable-syntax TypeScript, no loader needed). +// +// Used by: +// - snap tests: the harness packs the checkout once per run (see +// `localVitePlusPackages` in `snap-test.ts`) and fixtures wrap commands with +// `node $SNAP_LOCAL_REGISTRY -- vp ...`. Fixtures may also provide a +// `./mock-manifest.json` (keyed by URL path, e.g. `"@your-org/create"`) and +// `./tarballs/` in their case directory (the `create-org-*` cases). +// - ecosystem e2e: `patch-project.ts` serves the e2e tgz artifacts with +// `--packages-dir` so `vp migrate` / `vp install` resolve the local build +// through the standard registry code paths. +// - local development: run `vp migrate` / `vp create` against the checkout +// from any project directory without publishing anything: +// node /packages/tools/src/local-npm-registry.ts --pack -- vp migrate --no-interactive +// or keep a server running and export the printed env for repeated runs: +// node /packages/tools/src/local-npm-registry.ts --pack --serve +// +// Usage: +// node local-npm-registry.ts [--packages-dir ] [--pack] [--serve] [-- [args...]] +// node local-npm-registry.ts --ps | --kill +// +// --packages-dir serve every *.tgz in (defaults to +// $SNAP_LOCAL_VP_PACKAGES_DIR when set) +// --pack pack the checkout's vite-plus and +// @voidzero-dev/vite-plus-core into a temp dir first +// --serve keep the server running and print the registry URL +// and env exports instead of wrapping a command +// --ps list running local registry processes +// --kill kill them all and remove their leftover temp caches + +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { + createServer, + type IncomingMessage, + type OutgoingHttpHeaders, + type ServerResponse, +} from 'node:http'; +import { Agent as HttpsAgent, get as httpsGet, request as httpsRequest } from 'node:https'; +import { homedir, tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync } from 'node:zlib'; + +import { packLocalVitePlusPackages } from './pack-local-vite-plus.ts'; + +interface PackageManifest { + name: string; + version: string; + [key: string]: unknown; +} + +const args = process.argv.slice(2); +const separatorIndex = args.indexOf('--'); +const flags = separatorIndex === -1 ? args : args.slice(0, separatorIndex); +const command = separatorIndex === -1 ? [] : args.slice(separatorIndex + 1); + +let packagesDir = process.env.SNAP_LOCAL_VP_PACKAGES_DIR; +let pack = false; +let serve = false; +let listProcesses = false; +let killProcesses = false; +for (let i = 0; i < flags.length; i++) { + if (flags[i] === '--packages-dir' && flags[i + 1]) { + packagesDir = flags[++i]; + } else if (flags[i] === '--pack') { + pack = true; + } else if (flags[i] === '--serve') { + serve = true; + } else if (flags[i] === '--ps') { + listProcesses = true; + } else if (flags[i] === '--kill') { + killProcesses = true; + } else { + console.error(`local-npm-registry: unknown option ${flags[i]}`); + process.exit(2); + } +} + +// `--ps` / `--kill`: troubleshoot registry processes left behind by +// interrupted runs (the wrapper and --serve clean up after themselves, but a +// hard kill skips the handlers). A registry process is a `node` executable +// running this script; requiring `node` as the executing binary (start of +// the command line or preceded by a path separator) keeps shells whose +// command STRING merely mentions the script (`sh -c 'node ...'`, this very +// invocation's pnpm wrapper) out of the kill list. +const REGISTRY_PROCESS_RE = /(^|[/\\])node(\.exe)?['"]?\s[^\n]*local-npm-registry\.ts/; + +function findRegistryProcesses(): { pid: number; command: string }[] { + const result = + process.platform === 'win32' + ? spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-Command', + 'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }', + ], + { encoding: 'utf8' }, + ) + : spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8' }); + return (result.stdout ?? '') + .split('\n') + .map((line) => line.trim()) + .map((line) => { + const space = line.indexOf(' '); + return { pid: Number(line.slice(0, space)), command: line.slice(space + 1).trim() }; + }) + .filter( + ({ pid, command: cmd }) => + Number.isFinite(pid) && pid !== process.pid && REGISTRY_PROCESS_RE.test(cmd), + ); +} + +if (listProcesses || killProcesses) { + const processes = findRegistryProcesses(); + const lines = processes.map( + ({ pid, command: cmd }) => `${killProcesses ? 'killed ' : ''}${pid} ${cmd}`, + ); + if (processes.length === 0) { + lines.push('No local registry processes running'); + } + if (killProcesses) { + for (const { pid } of processes) { + try { + process.kill(pid); + } catch { + // already gone + } + } + let removed = 0; + for (const entry of readdirSync(tmpdir())) { + if (entry.startsWith('vp-local-registry-')) { + rmSync(path.join(tmpdir(), entry), { recursive: true, force: true }); + removed++; + } + } + lines.push(`Removed ${removed} leftover temp cache dir(s)`); + } + process.stdout.write(`${lines.join('\n')}\n`); + process.exit(0); +} + +if (!serve && command.length === 0) { + console.error( + 'usage: node local-npm-registry.ts [--packages-dir ] [--pack] [--serve] [-- [args...]] | --ps | --kill', + ); + process.exit(2); +} + +// The script lives at `packages/tools/src/`, so the repo root is three up. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +// Tracked so the normal cleanup paths remove the packed tarballs; a hard +// kill leaves it behind, which `--kill` sweeps up with the other +// vp-local-registry-* leftovers. +let packedDir: string | undefined; +if (pack && !packagesDir) { + packedDir = mkdtempSync(path.join(tmpdir(), 'vp-local-registry-pack-')); + await packLocalVitePlusPackages(repoRoot, packedDir); + packagesDir = packedDir; +} + +const manifest: Record = existsSync('./mock-manifest.json') + ? (JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')) as Record) + : {}; + +// Proxy through the configured registry (the project's `.npmrc` in cwd, then +// the user's `~/.npmrc`, e.g. a local mirror) when there is one, so runs stay +// as fast as direct installs and projects that rely on a custom registry keep +// resolving from it. CI has no registry config and uses npmjs. Deliberately +// reads only `.npmrc` files and NOT registry env vars (unlike the CLI's +// getNpmRegistry): a leftover NPM_CONFIG_REGISTRY export from a previous +// `--serve` session would otherwise become this server's own upstream (the +// https-only guard below rejects such http URLs for the same reason). +// Known out-of-scope for this test tool: HTTP(S)_PROXY tunneling and +// authenticated upstream registries; the environments it serves (repo CI and +// local dev) need neither. +function resolveUpstreamRegistry(): string { + for (const npmrcPath of [path.resolve('.npmrc'), path.join(homedir(), '.npmrc')]) { + try { + const npmrc = readFileSync(npmrcPath, 'utf-8'); + const registryLine = npmrc.split('\n').find((line) => line.trim().startsWith('registry=')); + const registry = registryLine?.split('=')[1]?.trim().replace(/\/+$/, ''); + // The proxy fetches with node:https, so only accept https upstreams. + if (registry?.startsWith('https://')) { + return registry; + } + } catch { + // no such .npmrc: try the next one + } + } + return 'https://registry.npmjs.org'; +} + +const UPSTREAM_REGISTRY = resolveUpstreamRegistry(); + +// Reuse upstream connections: an install fetches hundreds of packuments, and +// a fresh TLS handshake per request multiplies into minutes of overhead. +const upstreamAgent = new HttpsAgent({ keepAlive: true, maxSockets: 64 }); + +// Minimal ustar walk. The manifest is always the `package/package.json` entry +// in a pnpm-packed tarball, so long-name (pax header) handling is unnecessary. +function readPackageJsonFromTarball(tgzBytes: Buffer, sourcePath: string): PackageManifest { + const tar = gunzipSync(tgzBytes); + for (let offset = 0; offset + 512 <= tar.length; ) { + const rawName = tar.subarray(offset, offset + 100).toString(); + const nulIndex = rawName.indexOf('\0'); + const name = nulIndex === -1 ? rawName : rawName.slice(0, nulIndex); + if (!name) { + break; // end-of-archive marker + } + const size = + Number.parseInt( + tar + .subarray(offset + 124, offset + 136) + .toString() + .trim(), + 8, + ) || 0; + if (name === 'package/package.json') { + return JSON.parse( + tar.subarray(offset + 512, offset + 512 + size).toString(), + ) as PackageManifest; + } + offset += 512 + Math.ceil(size / 512) * 512; + } + throw new Error(`package/package.json not found in ${sourcePath}`); +} + +interface Packument { + name: string; + 'dist-tags': Record; + versions: Record; + time: Record; +} + +const localTarballs = new Map(); // tarball basename -> absolute path +const localPackuments = new Map(); // package name -> local-only packument +// Far in the past so package-manager minimum-release-age gates never +// quarantine the locally served versions. +const LOCAL_PACKAGE_TIME = '2020-01-01T00:00:00.000Z'; +if (packagesDir) { + for (const basename of readdirSync(packagesDir)) { + if (!basename.endsWith('.tgz')) { + continue; + } + const tgzPath = path.join(packagesDir, basename); + const bytes = readFileSync(tgzPath); + const pkg = readPackageJsonFromTarball(bytes, tgzPath); + localTarballs.set(basename, tgzPath); + localPackuments.set(pkg.name, { + name: pkg.name, + 'dist-tags': { latest: pkg.version }, + versions: { + [pkg.version]: { + ...pkg, + dist: { + tarball: `{REGISTRY}/${pkg.name}/-/${basename}`, + // Integrity over the exact bytes served, so every package manager + // that verifies it (npm, pnpm, yarn, bun) gets a match. + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + shasum: createHash('sha1').update(bytes).digest('hex'), + }, + }, + }, + time: { + created: LOCAL_PACKAGE_TIME, + modified: LOCAL_PACKAGE_TIME, + [pkg.version]: LOCAL_PACKAGE_TIME, + }, + }); + } +} + +// Serialize a manifest/packument with its `{REGISTRY}` placeholders (only our +// local tarball URLs carry them) pointed at the live server address. +function serializeForRegistry(value: unknown, registry: string): string { + return JSON.stringify(value).replaceAll('{REGISTRY}', registry); +} + +function fetchUpstreamPackument(name: string): Promise { + return new Promise((resolve) => { + httpsGet( + `${UPSTREAM_REGISTRY}/${name.replace('/', '%2F')}`, + { headers: { accept: 'application/json', 'accept-encoding': 'gzip' }, agent: upstreamAgent }, + (upstream) => { + if (upstream.statusCode !== 200) { + upstream.resume(); + resolve(null); + return; + } + const chunks: Buffer[] = []; + upstream.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstream.on('error', () => resolve(null)); + upstream.on('end', () => { + try { + const body = Buffer.concat(chunks); + const json = upstream.headers['content-encoding'] === 'gzip' ? gunzipSync(body) : body; + resolve(JSON.parse(json.toString()) as Packument); + } catch { + resolve(null); + } + }); + }, + ).on('error', () => resolve(null)); + }); +} + +// Serve local packages as an overlay on the real upstream packument (like the +// production registry bridge): upstream versions, times, and dist-tags stay +// visible, the local version is injected (winning over a published version +// with the same number), and `latest` points at it. Projects that already +// reference PUBLISHED Vite+ versions in their committed lockfiles (ecosystem +// repos on a previous release) can then still verify those entries, e.g. +// pnpm's time-based resolution policies need `time` for every lockfile entry. +// Falls back to the local-only packument when upstream is unreachable or the +// package has never been published. +const mergedPackuments = new Map(); + +async function resolveLocalPackument(name: string): Promise { + const local = localPackuments.get(name) as Packument; + const cached = mergedPackuments.get(name); + if (cached) { + return cached; + } + const upstream = await fetchUpstreamPackument(name); + const localVersion = local['dist-tags'].latest; + const merged: Packument = upstream + ? { + ...upstream, + name, + 'dist-tags': { ...upstream['dist-tags'], ...local['dist-tags'] }, + versions: { ...upstream.versions, ...local.versions }, + // Keep upstream created/modified; only the local version's publish + // time is ours. + time: { ...local.time, ...upstream.time, [localVersion]: LOCAL_PACKAGE_TIME }, + } + : local; + mergedPackuments.set(name, merged); + return merged; +} + +// Stream the upstream response byte-for-byte. Unlike `fetch`, `https` does not +// auto-decompress, so the tarball reaches the client exactly as the registry +// served it (content-encoding and all). bun verifies tarball integrity and +// rejects any re-encoded body, so faithful streaming is what lets bun installs +// work through the proxy (pnpm fetches tarballs from the upstream URL directly, +// so it was never affected). +function copyHeaders( + from: IncomingMessage['headers'], + to: OutgoingHttpHeaders, + names: readonly string[], +): void { + for (const name of names) { + if (from[name] !== undefined) { + to[name] = from[name]; + } + } +} + +function proxyToUpstream(req: IncomingMessage, res: ServerResponse): void { + // Stream errors can fire after headers are already sent (e.g. the upstream + // connection resets mid-tarball), so guard against a second writeHead. + const fail = (error: Error) => { + if (!res.headersSent) { + res.writeHead(502); + } + res.end(`proxy error: ${error.message}`); + }; + // Forward the original method and body: npm-based clients POST to registry + // endpoints too (e.g. the audit bulk advisories). The body can only be + // streamed into the FIRST upstream request, so redirects are followed only + // for body-less methods; a redirect on anything else is forwarded to the + // client as-is (its `location` header survives below). + const method = req.method ?? 'GET'; + const bodyless = method === 'GET' || method === 'HEAD'; + const fetchUrl = (url: string, redirectsLeft: number) => { + // Tarballs: `accept-encoding: identity` keeps mirrors/CDNs from wrapping + // them in an extra content-encoding layer, which some package managers + // cannot unwrap when verifying/extracting. Metadata: honor the client's + // own accept-encoding: the body is streamed through verbatim with its + // content-encoding header, so it must be exactly what the client can + // decode (package managers ask for gzip, which keeps huge packuments like + // vite/typescript fast; vp's Rust registry client asks for identity). + const headers: OutgoingHttpHeaders = { + accept: req.headers.accept ?? 'application/json', + 'accept-encoding': req.url?.endsWith('.tgz') + ? 'identity' + : (req.headers['accept-encoding'] ?? 'identity'), + }; + copyHeaders(req.headers, headers, ['content-type', 'content-length']); + const upstreamRequest = httpsRequest( + url, + { method, headers, agent: upstreamAgent }, + (upstream) => { + const status = upstream.statusCode ?? 502; + if ( + status >= 300 && + status < 400 && + upstream.headers.location && + redirectsLeft > 0 && + bodyless + ) { + // Draining can still emit 'error' (e.g. the socket resets mid-redirect), + // so guard it here too — otherwise it's uncaught and crashes the server. + upstream.on('error', fail); + upstream.resume(); + fetchUrl(new URL(upstream.headers.location, url).toString(), redirectsLeft - 1); + return; + } + const responseHeaders: OutgoingHttpHeaders = {}; + // Forward `location` too, so a 3xx we stop following (or one with no + // location to follow) still reaches the client with its redirect target. + copyHeaders(upstream.headers, responseHeaders, [ + 'content-type', + 'content-encoding', + 'content-length', + 'location', + ]); + // Default a missing content-type (parity with the prior fetch-based proxy) + // so clients that key off it still recognize a proxied tarball. + responseHeaders['content-type'] ??= 'application/octet-stream'; + res.writeHead(status, responseHeaders); + // `pipe` does not forward source errors, so listen on the response stream + // directly; otherwise a mid-stream upstream error is uncaught and crashes + // the mock server. + upstream.on('error', fail); + upstream.pipe(res); + }, + ); + upstreamRequest.on('error', fail); + if (bodyless) { + upstreamRequest.end(); + } else { + req.pipe(upstreamRequest); + } + }; + fetchUrl(`${UPSTREAM_REGISTRY}${req.url ?? '/'}`, 5); +} + +const server = createServer(async (req, res) => { + const key = decodeURIComponent(req.url ?? '/').replace(/^\/+/, ''); + if (localPackuments.has(key) || Object.hasOwn(manifest, key)) { + const address = server.address(); + const registry = + address && typeof address !== 'string' ? `http://127.0.0.1:${address.port}` : ''; + const packument = localPackuments.has(key) ? await resolveLocalPackument(key) : manifest[key]; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(serializeForRegistry(packument, registry)); + return; + } + const tarMatch = key.match(/\/-\/([^/]+\.tgz)$/); + if (tarMatch) { + try { + // Async read: local tarballs run tens of MB, and a synchronous read + // would stall the proxied requests of the same in-flight install. + const bytes = await readFile( + localTarballs.get(tarMatch[1]) ?? path.resolve('./tarballs', tarMatch[1]), + ); + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(bytes); + return; + } catch { + // fall through to proxy + } + } + // Proxy anything we don't mock (pnpm/latest, tarball downloads for real + // packages, etc.) to the upstream registry. Keeps the local packages in + // charge while letting everything else (package-manager download, real + // dependencies) resolve normally. + proxyToUpstream(req, res); +}); + +// Registry env for every package manager, each of which reads its own +// spelling: npm and bun honor NPM_CONFIG_REGISTRY, pnpm >= 10.6 only reads +// PNPM_CONFIG_* (older pnpm read the lowercase npm_config_* form), and Yarn +// Berry only reads YARN_-prefixed settings and refuses plain-http registries +// unless the host is whitelisted. +// +// Yarn Berry persists packuments (with our ephemeral-port tarball URLs) in +// its global folder, and bun's install cache trusts name@version without +// refetching, so a later run would reuse dead URLs or stale local package +// bytes. Give each run throwaway caches instead. +function buildRegistryEnv(registry: string): Record { + // In a proxied environment (HTTP_PROXY/HTTPS_PROXY without a loopback + // no-proxy entry), package managers would send the 127.0.0.1 registry + // requests through the proxy and never reach this server; extend the + // no-proxy list so loopback traffic always goes direct. + const noProxy = [process.env.NO_PROXY ?? process.env.no_proxy, '127.0.0.1'] + .filter(Boolean) + .join(','); + return { + NPM_CONFIG_REGISTRY: registry, + npm_config_registry: registry, + PNPM_CONFIG_REGISTRY: registry, + YARN_NPM_REGISTRY_SERVER: registry, + YARN_UNSAFE_HTTP_WHITELIST: '127.0.0.1', + NO_PROXY: noProxy, + no_proxy: noProxy, + NPM_CONFIG_NOPROXY: noProxy, + npm_config_noproxy: noProxy, + YARN_GLOBAL_FOLDER: mkdtempSync(path.join(tmpdir(), 'vp-local-registry-yarn-')), + BUN_INSTALL_CACHE_DIR: mkdtempSync(path.join(tmpdir(), 'vp-local-registry-bun-')), + }; +} + +function cleanupRegistryEnv(env: Record): void { + rmSync(env.YARN_GLOBAL_FOLDER, { recursive: true, force: true }); + rmSync(env.BUN_INSTALL_CACHE_DIR, { recursive: true, force: true }); + if (packedDir) { + rmSync(packedDir, { recursive: true, force: true }); + } +} + +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + console.error('local-npm-registry: failed to bind'); + process.exit(1); + } + const registry = `http://127.0.0.1:${address.port}`; + const registryEnv = buildRegistryEnv(registry); + + if (serve) { + // First line is machine-readable so callers (e.g. patch-project.ts) can + // spawn `--serve` and read the URL and env; the rest is copy-paste for + // humans. + const lines = [ + JSON.stringify({ registry, env: registryEnv }), + '', + `# serving local packages${packagesDir ? ` from ${packagesDir}` : ''}`, + ...[...localPackuments.keys(), ...Object.keys(manifest)].map((name) => `# ${name}`), + '# run commands against it with:', + ...Object.entries(registryEnv).map(([key, value]) => `export ${key}=${value}`), + ]; + process.stdout.write(`${lines.join('\n')}\n`); + const shutdown = () => { + cleanupRegistryEnv(registryEnv); + // Exit without waiting for `server.close()`: idle keep-alive + // connections from package managers would delay the callback + // indefinitely, leaking the process. + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + return; + } + + const [cmd, ...cmdArgs] = command as [string, ...string[]]; + const child = spawn(cmd, cmdArgs, { + env: { ...process.env, ...registryEnv }, + stdio: 'inherit', + }); + child.on('exit', (code, signal) => { + cleanupRegistryEnv(registryEnv); + const exitCode = code ?? (signal ? 128 : 0); + server.close(() => process.exit(exitCode)); + }); +}); diff --git a/packages/tools/src/pack-local-vite-plus.ts b/packages/tools/src/pack-local-vite-plus.ts new file mode 100644 index 0000000000..aaa9cd0b6f --- /dev/null +++ b/packages/tools/src/pack-local-vite-plus.ts @@ -0,0 +1,61 @@ +// Pack the checkout's publishable Vite+ packages (vite-plus and +// @voidzero-dev/vite-plus-core) so a local npm registry can serve them at the +// checkout version. Shared by the snap-test harness (once-per-run pack) and +// local-npm-registry.ts --pack (standalone dev / e2e invocations). +// +// Uses node builtins only and erasable TypeScript syntax, so +// local-npm-registry.ts stays runnable with bare `node` from any directory. + +import { execFile } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export async function packLocalVitePlusPackages( + repoRoot: string, + destination: string, +): Promise { + for (const sentinel of ['cli/dist/bin.js', 'core/dist']) { + if (!existsSync(path.join(repoRoot, 'packages', sentinel))) { + throw new Error( + `Cannot pack local Vite+ packages: packages/${sentinel} is missing. Run \`pnpm build\` first.`, + ); + } + } + // `vite-plus` packs `binding/*.node` (see its `files` field), and created + // projects run `vp config` (their `prepare` script) from their OWN + // installed vite-plus, so the host binding must exist for the installed + // package to be functional. + const bindingDir = path.join(repoRoot, 'packages', 'cli', 'binding'); + const hostBindingPrefix = `vite-plus.${process.platform}-${process.arch}`; + const hasHostBinding = readdirSync(bindingDir).some( + (entry) => entry.startsWith(hostBindingPrefix) && entry.endsWith('.node'), + ); + if (!hasHostBinding) { + throw new Error( + `Cannot pack local Vite+ packages: no ${hostBindingPrefix}*.node in ${bindingDir}. ` + + 'Run `pnpm bootstrap-cli` (or build the NAPI binding) first.', + ); + } + await execFileAsync( + 'pnpm', + [ + '--filter', + 'vite-plus', + '--filter', + '@voidzero-dev/vite-plus-core', + 'pack', + '--pack-destination', + destination, + ], + { cwd: repoRoot, timeout: 120_000, shell: process.platform === 'win32' }, + ); + const packed = readdirSync(destination).filter((entry) => entry.endsWith('.tgz')); + if (packed.length !== 2) { + throw new Error( + `Expected 2 packed local Vite+ packages in ${destination}, found: ${packed.join(', ') || 'none'}`, + ); + } +} diff --git a/packages/tools/src/repack-vite-tgz.ts b/packages/tools/src/repack-vite-tgz.ts deleted file mode 100644 index c97cdf07e5..0000000000 --- a/packages/tools/src/repack-vite-tgz.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; - -import { createTarGzip, parseTarGzip, type TarFileInput } from 'nanotar'; - -interface PackageJson { - name?: string; - version?: string; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; -} - -function stripVitePlusCoreSelfRefs(pkg: PackageJson, newName: string): void { - for (const field of [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', - ] as const) { - const group = pkg[field]; - if (!group) { - continue; - } - for (const [key, value] of Object.entries(group)) { - if ( - (key === newName || key === '@voidzero-dev/vite-plus-core') && - typeof value === 'string' && - value.includes('@voidzero-dev/vite-plus-core') - ) { - delete group[key]; - } - } - } -} - -export async function repackViteTgz() { - const [inputPath, outputPath, newName, newVersion] = process.argv.slice(3); - - if (!inputPath || !outputPath || !newName) { - console.error('Usage: tool repack-vite-tgz [new-version]'); - process.exit(1); - } - - const inputBytes = await readFile(inputPath); - const entries = await parseTarGzip(inputBytes); - - let patched = 0; - const repacked: TarFileInput[] = entries.map((entry) => { - let data = entry.data; - if (entry.name === 'package/package.json' && data) { - const pkg = JSON.parse(new TextDecoder().decode(data)) as PackageJson; - pkg.name = newName; - if (newVersion) { - pkg.version = newVersion; - } - // Strip any self-ref (`vite: npm:@voidzero-dev/vite-plus-core@...`) - // injected by the workspace-level vite -> vite-plus-core override at - // pnpm pack time. Once we rename the tgz to "vite", this becomes a - // circular self-dependency that confuses pnpm's resolver and triggers a - // registry lookup for the alias target version. - stripVitePlusCoreSelfRefs(pkg, newName); - data = new TextEncoder().encode(JSON.stringify(pkg, null, 2) + '\n'); - patched += 1; - } - return { name: entry.name, data, attrs: entry.attrs }; - }); - - if (patched !== 1) { - console.error(`Expected exactly one package/package.json entry, found ${patched}`); - process.exit(1); - } - - const outBytes = await createTarGzip(repacked); - await writeFile(outputPath, outBytes); - console.log( - `Repacked ${inputPath} -> ${outputPath} (name=${newName}${ - newVersion ? `, version=${newVersion}` : '' - }, ${outBytes.byteLength} bytes)`, - ); -} diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index b8c0bdde1b..1f5651c4b0 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -9,10 +9,41 @@ import { debuglog, parseArgs } from 'node:util'; import { npath } from '@yarnpkg/fslib'; import { execute } from '@yarnpkg/shell'; +import { packLocalVitePlusPackages } from './pack-local-vite-plus.ts'; import { isPassThroughEnv, replaceUnstableOutput } from './utils.js'; const debug = debuglog('vite-plus/snap-test'); +let localVpPackagesPromise: Promise | undefined; + +/** + * Pack the checkout's `vite-plus` and `@voidzero-dev/vite-plus-core` once per + * run, for fixtures with `localVitePlusPackages: true`. Commands routed + * through `local-npm-registry.ts` serve these tarballs at the checkout + * version, so real package-manager installs work without the version being + * published on npm (e.g. on release branches) and exercise the actual local + * build. + * + * The destination lives inside the run's temp root rather than a fixed + * directory on purpose: a fresh pack per run (~2s) guarantees the served + * tarballs match the current checkout build (a reused fixed directory would + * silently serve stale tarballs after a rebuild unless it grew its own + * invalidation scheme), the run's exit cleanup deletes it for free, and + * concurrent snap-test processes (local + global) cannot overwrite each + * other's tarballs mid-read. + */ +function packLocalVitePlusPackagesOnce(casesDir: string, tempTmpDir: string): Promise { + localVpPackagesPromise ??= (async () => { + const destination = path.join(tempTmpDir, 'local-vite-plus-packages'); + fs.mkdirSync(destination, { recursive: true }); + const startTime = Date.now(); + await packLocalVitePlusPackages(resolveRepoRoot(casesDir), destination); + console.log('Packed local Vite+ packages in %dms', Date.now() - startTime); + return destination; + })(); + return localVpPackagesPromise; +} + // Remove comments (starting with ' #') from command strings // `@yarnpkg/shell` doesn't parse comments. // This doesn't handle all edge cases (such as ' #' in quoted strings), but is good enough for our test cases. @@ -480,8 +511,19 @@ interface PlatformFilter { interface Steps { ignoredPlatforms?: (string | PlatformFilter)[]; - env: Record; + env?: Record; commands: (string | Command)[]; + /** + * If true, the harness packs the checkout's `vite-plus` and + * `@voidzero-dev/vite-plus-core` (once per run) and exposes the tarball + * directory as `SNAP_LOCAL_VP_PACKAGES_DIR`. Commands wrapped with + * `node $SNAP_LOCAL_REGISTRY -- ...` then install these local packages at + * the checkout version instead of resolving it from the npm registry + * (which would fail on release branches before the version is published). + * These fixtures run real cold-cache installs, so the default command + * timeout is raised to 120s. + */ + localVitePlusPackages?: boolean; /** * If true, installed Vite+ packages in the test project are relinked to the * current checkout after each successful command. @@ -598,10 +640,15 @@ async function runTestCase( VP_SKIP_INSTALL: '1', // make sure npm install global packages to the temporary directory NPM_CONFIG_PREFIX: path.join(tempTmpDir, NPM_GLOBAL_PREFIX_DIR), - // Absolute path to the source casesDir, so fixtures can reference - // shared helper scripts under `/.shared/` without - // duplicating them into every fixture directory. - SNAP_CASES_DIR: casesDir, + // Absolute path to the local npm registry wrapper, so fixtures can route + // installs through it (`node $SNAP_LOCAL_REGISTRY -- vp ...`). + SNAP_LOCAL_REGISTRY: path.join( + resolveRepoRoot(casesDir), + 'packages', + 'tools', + 'src', + 'local-npm-registry.ts', + ), // Global CLI snap tests execute the Rust binary from --bin-dir, but the JS // entry should come from this checkout instead of a stale ~/.vite-plus install. ...(globalCliScriptsDir ? { VITE_GLOBAL_CLI_JS_SCRIPTS_DIR: globalCliScriptsDir } : {}), @@ -611,6 +658,10 @@ async function runTestCase( ...steps.env, }; + if (steps.localVitePlusPackages) { + env['SNAP_LOCAL_VP_PACKAGES_DIR'] = await packLocalVitePlusPackagesOnce(casesDir, tempTmpDir); + } + // Unset VP_NODE_VERSION to prevent `vp env use` session overrides // from leaking into snap tests. delete env['VP_NODE_VERSION']; @@ -670,7 +721,9 @@ async function runTestCase( match: async () => [], }, }), - setTimeout(cmd.timeout ?? 50 * 1000), + // Fixtures that install local Vite+ packages run real cold-cache + // installs, so their commands get a higher default ceiling. + setTimeout(cmd.timeout ?? (steps.localVitePlusPackages ? 120 : 50) * 1000), ]); await outputStream.close(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 212133116f..1c8708689a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,9 +684,6 @@ importers: '@yarnpkg/shell': specifier: 'catalog:' version: 4.1.3(typanion@3.14.0) - nanotar: - specifier: 'catalog:' - version: 0.3.0 devDependencies: '@oxc-node/cli': specifier: 'catalog:'