From 51a8054b5151aa2f16dcb275ff8caee3a887b7ea Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 2 Jul 2026 19:59:53 +0800 Subject: [PATCH 01/14] test(snap): install locally packed vite-plus packages in install fixtures Install fixtures no longer pin VP_VERSION to a published release. The snap harness packs the checkout's vite-plus and @voidzero-dev/vite-plus-core once per run (localVitePlusPackages in steps.json), and mock-npm-registry.mjs serves the tarballs as single-version packuments (integrity over the exact bytes served, old time so minimum-release-age gates never quarantine them), proxying everything else upstream. Installs therefore resolve the checkout's own version even when it is not published yet, and release CI exercises the actual to-be-released packages through vp create / vp migrate. Wrapper fixes found while validating against an unpublished version: - pnpm 11 ignores npm_config_registry / NPM_CONFIG_REGISTRY, so the wrapper also sets PNPM_CONFIG_REGISTRY (pnpm was silently resolving from the real registry before) - Yarn Berry persists packuments with dead ephemeral-port tarball URLs and bun's install cache trusts name@version without refetching, so both get throwaway caches per invocation - the proxy reuses upstream connections, honors the user's ~/.npmrc registry (mirror-friendly locally, npmjs on CI), and forces identity encoding for tarballs only (mirror CDNs gzip them, which bun cannot extract) - wrapped install commands get a 120s timeout for cold-cache runs Closes #2018 --- .../create-framework-shim-astro/snap.txt | 4 +- .../create-framework-shim-astro/steps.json | 15 +- .../create-framework-shim-vue/snap.txt | 4 +- .../create-framework-shim-vue/steps.json | 15 +- .../snap.txt | 2 +- .../steps.json | 7 +- .../migration-framework-shim-astro/snap.txt | 2 +- .../migration-framework-shim-astro/steps.json | 7 +- .../migration-framework-shim-vue/snap.txt | 2 +- .../migration-framework-shim-vue/steps.json | 7 +- .../migration-standalone-npm/snap.txt | 2 +- .../migration-standalone-npm/steps.json | 7 +- .../migration-standalone-pnpm/snap.txt | 2 +- .../migration-standalone-pnpm/steps.json | 7 +- .../snap-tests/.shared/mock-npm-registry.mjs | 162 ++++++++++++++++-- .../create-approve-builds-bun/snap.txt | 6 +- .../create-approve-builds-bun/steps.json | 14 +- .../snap.txt | 6 +- .../steps.json | 14 +- .../create-approve-builds-pnpm11/snap.txt | 6 +- .../create-approve-builds-pnpm11/steps.json | 14 +- .../create-approve-builds-yarn/snap.txt | 4 +- .../create-approve-builds-yarn/steps.json | 12 +- .../migration-standalone-bun-install/snap.txt | 2 +- .../steps.json | 7 +- packages/tools/src/snap-test.ts | 92 +++++++++- 26 files changed, 347 insertions(+), 75 deletions(-) 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..a39bcada95 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_SHARED_DIR/mock-npm-registry.mjs -- 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_SHARED_DIR/mock-npm-registry.mjs -- 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..3fb9fb4206 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,17 +1,18 @@ { "ignoredPlatforms": ["win32"], - "env": { - "VP_VERSION": "0.2.1" - }, + "localVitePlusPackages": true, + "env": {}, "commands": [ { - "command": "vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app", - "ignoreOutput": true + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app", + "ignoreOutput": true, + "timeout": 120000 }, "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", - "ignoreOutput": true + "command": "cd my-astro-app && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp install --ignore-scripts -- --no-frozen-lockfile # install dependencies", + "ignoreOutput": true, + "timeout": 120000 }, { "command": "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" 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..bbf422fdcb 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_SHARED_DIR/mock-npm-registry.mjs -- 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_SHARED_DIR/mock-npm-registry.mjs -- 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..02a8f19e78 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,18 +1,19 @@ { "ignoredPlatforms": ["win32", { "os": "linux", "libc": "musl" }], "linkCheckoutPackages": true, - "env": { - "VP_VERSION": "0.2.1" - }, + "localVitePlusPackages": true, + "env": {}, "commands": [ { - "command": "vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app", - "ignoreOutput": true + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app", + "ignoreOutput": true, + "timeout": 120000 }, "cat vite-plus-application/src/env.d.ts # check Vue shim was added", { - "command": "cd vite-plus-application && vp install # install dependencies", - "ignoreOutput": true + "command": "cd vite-plus-application && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp install # install dependencies", + "ignoreOutput": true, + "timeout": 120000 }, "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..5ae4285da8 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_SHARED_DIR/mock-npm-registry.mjs -- 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..4fedc26c4c 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,16 @@ { "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", + { + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims", + "timeout": 120000 + }, "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..c55e3cbcee 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_SHARED_DIR/mock-npm-registry.mjs -- 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..fda39335cb 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,16 @@ { "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", + { + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected", + "timeout": 120000 + }, "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..237cc137ee 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_SHARED_DIR/mock-npm-registry.mjs -- 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..c8965186a1 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,16 @@ { "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", + { + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected", + "timeout": 120000 + }, "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..ec87336085 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_SHARED_DIR/mock-npm-registry.mjs -- 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..c3559ab0e0 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,16 @@ { "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", + { + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", + "timeout": 120000 + }, "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..23dada4743 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_SHARED_DIR/mock-npm-registry.mjs -- 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..fb0e465c64 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,16 @@ { "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", + { + "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml", + "timeout": 120000 + }, "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 index 7fffd0fa5f..9289cb811d 100644 --- a/packages/cli/snap-tests/.shared/mock-npm-registry.mjs +++ b/packages/cli/snap-tests/.shared/mock-npm-registry.mjs @@ -1,20 +1,121 @@ -// Minimal mock npm registry used by `create-org-*` snap-tests. +// Minimal mock npm registry used by snap-tests that need install-time control +// over specific packages (`create-org-*`, and every fixture that installs the +// locally packed Vite+ packages). // -// 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. +// Reads `./mock-manifest.json` if present (keyed by URL path, e.g. +// `"@your-org/create"`) and optionally serves `.tgz` tarballs from +// `./tarballs/`. When `SNAP_LOCAL_VP_PACKAGES_DIR` is set (see +// `localVitePlusPackages` in the snap-test harness), every tarball in that +// directory is served as a single-version packument, so package managers can +// install the checkout's own `vite-plus` / `@voidzero-dev/vite-plus-core` +// even when that version is not published on npm. Picks an ephemeral port, +// points the package managers of the child environment at it, 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 { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; -import { get as httpsGet } from 'node:https'; +import { Agent as HttpsAgent, get as httpsGet } from 'node:https'; +import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; -const manifest = JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')); -const UPSTREAM_REGISTRY = 'https://registry.npmjs.org'; +const manifest = existsSync('./mock-manifest.json') + ? JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')) + : {}; + +// Proxy through the user's configured registry (e.g. a local mirror in +// `~/.npmrc`) when there is one, so local runs stay as fast as direct +// installs. CI has no user registry config and uses npmjs. +function resolveUpstreamRegistry() { + try { + const npmrc = readFileSync(path.join(homedir(), '.npmrc'), '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 user .npmrc: use the default registry + } + 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, sourcePath) { + const tar = gunzipSync(tgzBytes); + for (let offset = 0; offset + 512 <= tar.length; ) { + const name = tar + .subarray(offset, offset + 100) + .toString() + .replace(/\0[^]*$/, ''); + 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()); + } + offset += 512 + Math.ceil(size / 512) * 512; + } + throw new Error(`package/package.json not found in ${sourcePath}`); +} + +const localPackagesDir = process.env.SNAP_LOCAL_VP_PACKAGES_DIR; +const localTarballs = new Map(); // tarball basename -> absolute path +// 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 (localPackagesDir) { + for (const basename of readdirSync(localPackagesDir)) { + if (!basename.endsWith('.tgz')) { + continue; + } + const tgzPath = path.join(localPackagesDir, basename); + const bytes = readFileSync(tgzPath); + const pkg = readPackageJsonFromTarball(bytes, tgzPath); + localTarballs.set(basename, tgzPath); + manifest[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, + }, + }; + } +} function rewriteRegistry(value, registry) { return JSON.parse(JSON.stringify(value).replaceAll('{REGISTRY}', registry)); @@ -36,7 +137,20 @@ function proxyToUpstream(req, res) { res.end(`proxy error: ${error.message}`); }; const fetchUrl = (url, redirectsLeft) => { - httpsGet(url, { headers: { accept: req.headers.accept ?? 'application/json' } }, (upstream) => { + // 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 = { + accept: req.headers.accept ?? 'application/json', + 'accept-encoding': req.url?.endsWith('.tgz') + ? 'identity' + : (req.headers['accept-encoding'] ?? 'identity'), + }; + httpsGet(url, { headers, agent: upstreamAgent }, (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), @@ -81,7 +195,9 @@ const server = createServer(async (req, res) => { const tarMatch = key.match(/\/-\/([^/]+\.tgz)$/); if (tarMatch) { try { - const bytes = readFileSync(path.resolve('./tarballs', tarMatch[1])); + const bytes = readFileSync( + localTarballs.get(tarMatch[1]) ?? path.resolve('./tarballs', tarMatch[1]), + ); res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.end(bytes); return; @@ -110,11 +226,33 @@ server.listen(0, '127.0.0.1', () => { return; } const [cmd, ...args] = process.argv.slice(separatorIndex + 1); + // 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 invocation would reuse dead URLs or stale local + // package bytes. Give each invocation throwaway caches instead. + const yarnGlobalFolder = mkdtempSync(path.join(tmpdir(), 'vp-mock-registry-yarn-')); + const bunCacheDir = mkdtempSync(path.join(tmpdir(), 'vp-mock-registry-bun-')); const child = spawn(cmd, args, { - env: { ...process.env, NPM_CONFIG_REGISTRY: registry }, + env: { + ...process.env, + // Every package manager reads its own env 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. + 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', + YARN_GLOBAL_FOLDER: yarnGlobalFolder, + BUN_INSTALL_CACHE_DIR: bunCacheDir, + }, stdio: 'inherit', }); child.on('exit', (code, signal) => { + rmSync(yarnGlobalFolder, { recursive: true, force: true }); + rmSync(bunCacheDir, { recursive: true, force: true }); 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..17306fb3c2 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_SHARED_DIR/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) ◇ 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_SHARED_DIR/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 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_SHARED_DIR/mock-npm-registry.mjs -- 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..3e84257133 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,23 @@ { "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)", + { + "command": "node $SNAP_SHARED_DIR/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)", + "timeout": 120000 + }, "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", + { + "command": "node $SNAP_SHARED_DIR/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", + "timeout": 120000 + }, "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_SHARED_DIR/mock-npm-registry.mjs -- 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..975e02b362 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_SHARED_DIR/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 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_SHARED_DIR/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 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_SHARED_DIR/mock-npm-registry.mjs -- 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..558a03b00e 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,22 @@ { + "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", + { + "command": "node $SNAP_SHARED_DIR/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", + "timeout": 120000 + }, "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", + { + "command": "node $SNAP_SHARED_DIR/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", + "timeout": 120000 + }, "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_SHARED_DIR/mock-npm-registry.mjs -- 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..04ab187c4e 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_SHARED_DIR/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) ◇ 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_SHARED_DIR/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 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_SHARED_DIR/mock-npm-registry.mjs -- 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..b60e21b419 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,22 @@ { + "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)", + { + "command": "node $SNAP_SHARED_DIR/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)", + "timeout": 120000 + }, "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", + { + "command": "node $SNAP_SHARED_DIR/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", + "timeout": 120000 + }, "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_SHARED_DIR/mock-npm-registry.mjs -- 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..b6f29e9373 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_SHARED_DIR/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 ◇ 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_SHARED_DIR/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 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..11bc4092fe 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,20 @@ { + "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", + { + "command": "node $SNAP_SHARED_DIR/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", + "timeout": 120000 + }, "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", + { + "command": "node $SNAP_SHARED_DIR/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", + "timeout": 120000 + }, "cat default-app/package.json # no dependenciesMeta, the build was not run" ] } 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..44cfef9520 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_SHARED_DIR/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` 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..7346e32977 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,15 @@ { "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`", + { + "command": "node $SNAP_SHARED_DIR/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`", + "timeout": 120000 + }, "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/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index b8c0bdde1b..f17da26449 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -1,10 +1,11 @@ +import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import { cpus, homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { setTimeout } from 'node:timers/promises'; -import { debuglog, parseArgs } from 'node:util'; +import { debuglog, parseArgs, promisify } from 'node:util'; import { npath } from '@yarnpkg/fslib'; import { execute } from '@yarnpkg/shell'; @@ -13,6 +14,77 @@ import { isPassThroughEnv, replaceUnstableOutput } from './utils.js'; const debug = debuglog('vite-plus/snap-test'); +const execFileAsync = promisify(execFile); + +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 `.shared/mock-npm-registry.mjs` 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. + */ +function packLocalVitePlusPackages(casesDir: string, tempTmpDir: string): Promise { + localVpPackagesPromise ??= (async () => { + const repoRoot = resolveRepoRoot(casesDir); + for (const sentinel of ['cli/dist/bin.js', 'core/dist']) { + const sentinelPath = path.join(repoRoot, 'packages', sentinel); + if (!fs.existsSync(sentinelPath)) { + throw new Error( + `Cannot pack local Vite+ packages: ${sentinelPath} is missing.\n` + + 'Run `pnpm build` before snap tests that install local Vite+ packages.', + ); + } + } + // `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 = fs + .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}.\n` + + 'Run `pnpm bootstrap-cli` (or build the NAPI binding) first.', + ); + } + const destination = path.join(tempTmpDir, 'local-vite-plus-packages'); + fs.mkdirSync(destination, { recursive: true }); + const startTime = Date.now(); + 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 = fs.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'}`, + ); + } + console.log( + 'Packed local Vite+ packages in %dms: %s', + Date.now() - startTime, + packed.join(', '), + ); + 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. @@ -482,6 +554,16 @@ interface Steps { ignoredPlatforms?: (string | PlatformFilter)[]; 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 + * `$SNAP_SHARED_DIR/mock-npm-registry.mjs` 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). + */ + localVitePlusPackages?: boolean; /** * If true, installed Vite+ packages in the test project are relinked to the * current checkout after each successful command. @@ -602,6 +684,10 @@ async function runTestCase( // shared helper scripts under `/.shared/` without // duplicating them into every fixture directory. SNAP_CASES_DIR: casesDir, + // Shared helper scripts live under `snap-tests/.shared/` only; expose the + // directory independently of the active casesDir so `snap-tests-global` + // fixtures can use them too. + SNAP_SHARED_DIR: path.join(path.dirname(casesDir), 'snap-tests', '.shared'), // 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 +697,10 @@ async function runTestCase( ...steps.env, }; + if (steps.localVitePlusPackages) { + env['SNAP_LOCAL_VP_PACKAGES_DIR'] = await packLocalVitePlusPackages(casesDir, tempTmpDir); + } + // Unset VP_NODE_VERSION to prevent `vp env use` session overrides // from leaking into snap tests. delete env['VP_NODE_VERSION']; From b14dcf00029a4fb77eb46fa16aead7b19908da45 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 2 Jul 2026 20:57:22 +0800 Subject: [PATCH 02/14] test: generalize the local npm registry for e2e and local migrate testing Move the snap-test mock registry to packages/tools/src/local-npm-registry.mjs and make it a shared tool with three modes: wrap a command (snap fixtures via $SNAP_LOCAL_REGISTRY), --pack to self-pack the checkout from any project directory, and --serve to keep a server running (first stdout line is machine-readable JSON, followed by copy-paste env exports). Ecosystem e2e now installs the local build through the registry instead of file: specs: patch-project.ts starts one detached server for the whole job (env exported via GITHUB_ENV for later steps) and runs vp migrate/vp install through it with a real semver VP_VERSION read from the packed tgz. This removes the manual post-migrate package.json rewrite (migrate pins the version itself, catalog paths included), the bun vite-7.99.0 alias tgz (bun installs the standard npm:@voidzero-dev/vite-plus-core alias through a registry), and dify's file:-crash tolerance. verify-install.ts asserts the exact registry spec, accepting catalog references. For local development, vp migrate / vp create logic can be verified right after a build (about 16s) instead of publishing to pkg.pr.new and going through the registry bridge: node /packages/tools/src/local-npm-registry.mjs --pack -- vp migrate --no-interactive Documented in CONTRIBUTING.md, with a pnpm local-registry root script. Verified: bun-vite-template end-to-end (clone, migrate, bun install, verify-install) resolves everything through the local server with the installed vite-plus integrity differing from the published tarball; all 21 touched snap fixtures regenerate byte-identical; migrate/create/serve dev flows pass against scratch projects. --- .github/workflows/e2e-test.yml | 43 ++-- AGENTS.md | 1 + CONTRIBUTING.md | 22 ++ ecosystem-ci/patch-project.ts | 191 +++++++------- ecosystem-ci/verify-install.ts | 25 +- package.json | 1 + .../create-framework-shim-astro/snap.txt | 4 +- .../create-framework-shim-astro/steps.json | 4 +- .../create-framework-shim-vue/snap.txt | 4 +- .../create-framework-shim-vue/steps.json | 4 +- .../snap.txt | 2 +- .../steps.json | 2 +- .../migration-framework-shim-astro/snap.txt | 2 +- .../migration-framework-shim-astro/steps.json | 2 +- .../migration-framework-shim-vue/snap.txt | 2 +- .../migration-framework-shim-vue/steps.json | 2 +- .../migration-standalone-npm/snap.txt | 2 +- .../migration-standalone-npm/steps.json | 2 +- .../migration-standalone-pnpm/snap.txt | 2 +- .../migration-standalone-pnpm/steps.json | 2 +- .../create-approve-builds-bun/snap.txt | 6 +- .../create-approve-builds-bun/steps.json | 6 +- .../snap.txt | 6 +- .../steps.json | 6 +- .../create-approve-builds-pnpm11/snap.txt | 6 +- .../create-approve-builds-pnpm11/steps.json | 6 +- .../create-approve-builds-yarn/snap.txt | 4 +- .../create-approve-builds-yarn/steps.json | 4 +- .../create-org-bundled-dotfiles/snap.txt | 2 +- .../create-org-bundled-dotfiles/steps.json | 2 +- .../create-org-bundled-escape-check/snap.txt | 2 +- .../steps.json | 2 +- .../create-org-bundled-monorepo/snap.txt | 2 +- .../create-org-bundled-monorepo/steps.json | 2 +- .../snap-tests/create-org-bundled/snap.txt | 2 +- .../snap-tests/create-org-bundled/steps.json | 2 +- .../create-org-config-default/snap.txt | 2 +- .../create-org-config-default/steps.json | 2 +- .../create-org-invalid-manifest/snap.txt | 2 +- .../create-org-invalid-manifest/steps.json | 2 +- .../snap.txt | 2 +- .../steps.json | 2 +- .../create-org-monorepo-filter/snap.txt | 2 +- .../create-org-monorepo-filter/steps.json | 2 +- .../create-org-no-interactive-error/snap.txt | 2 +- .../steps.json | 2 +- .../migration-standalone-bun-install/snap.txt | 2 +- .../steps.json | 2 +- .../src/local-npm-registry.mjs} | 233 +++++++++++++----- packages/tools/src/snap-test.ts | 28 ++- 50 files changed, 411 insertions(+), 253 deletions(-) rename packages/{cli/snap-tests/.shared/mock-npm-registry.mjs => tools/src/local-npm-registry.mjs} (50%) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 8f8045a9c0..f58fbf8a9d 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -109,32 +109,20 @@ 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.mjs), so `vp migrate` pins + # and installs them like a real release. 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. The bun-only vite alias tgz that the old file:-override + # flow needed (bun peer-dep strictness, oven-sh/bun#8406) is gone: + # through the registry, bun installs the standard + # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. (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 @@ -438,13 +426,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/AGENTS.md b/AGENTS.md index 8c0d2f40f3..7428ae8a37 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.mjs` 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..3a8efb93ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,28 @@ 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.mjs` 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.mjs --pack -- vp migrate --no-interactive +node /path/to/vite-plus/packages/tools/src/local-npm-registry.mjs --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. + ### 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..c96d761271 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -1,5 +1,6 @@ -import { execSync } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; +import { execSync, spawn } from 'node:child_process'; +import { readdirSync } 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'; @@ -19,12 +20,76 @@ 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.mjs), so vp migrate pins and installs the checkout's +// own version through the standard registry code paths — no `file:` specs. +// The e2e build job pins packages/cli to 0.0.0 before `pnpm pack`; a local +// run can serve whatever version the checkout carries. +const vitePlusVersion = readdirSync(tgzDir) + .map((entry) => /^vite-plus-(\d.*)\.tgz$/.exec(entry)?.[1]) + .find(Boolean); +if (!vitePlusVersion) { + console.error(`No vite-plus-.tgz found in ${tgzDir}`); + process.exit(1); +} + +const registryScript = join( + import.meta.dirname, + '..', + 'packages', + 'tools', + 'src', + 'local-npm-registry.mjs', +); +// 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 +126,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 +143,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 +160,41 @@ 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. +const releaseAgeEnv = { + 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', + // 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, }; -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; -}; - -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/verify-install.ts b/ecosystem-ci/verify-install.ts index 212b741122..1f2601f212 100644 --- a/ecosystem-ci/verify-install.ts +++ b/ecosystem-ci/verify-install.ts @@ -4,11 +4,12 @@ import path from 'node:path'; 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'; +// The ecosystem-ci pack step pins packages/cli to 0.0.0 before `pnpm pack`, +// and patch-project.ts serves that build through the local registry, so a +// correctly installed local build always reports 0.0.0 — never a published +// registry version. A local (non-CI) run serves whatever version the checkout +// carries; pass it via VP_EXPECTED_VERSION. +const expectedVersion = process.env.VP_EXPECTED_VERSION ?? '0.0.0'; try { const pkgPath = require.resolve('vite-plus/package.json'); @@ -31,11 +32,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 +60,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..c5672648e0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "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.mjs", "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 a39bcada95..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 3fb9fb4206..a937e44c2b 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 @@ -4,13 +4,13 @@ "env": {}, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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, "timeout": 120000 }, "cat my-astro-app/src/env.d.ts # check Astro shim", { - "command": "cd my-astro-app && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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, "timeout": 120000 }, 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 bbf422fdcb..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 02a8f19e78..b3f5c39f4c 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 @@ -5,13 +5,13 @@ "env": {}, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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, "timeout": 120000 }, "cat vite-plus-application/src/env.d.ts # check Vue shim was added", { - "command": "cd vite-plus-application && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp install # install dependencies", + "command": "cd vite-plus-application && node $SNAP_LOCAL_REGISTRY -- vp install # install dependencies", "ignoreOutput": true, "timeout": 120000 }, 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 5ae4285da8..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 4fedc26c4c..b073c1d2f0 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 @@ -8,7 +8,7 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims", + "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims", "timeout": 120000 }, "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 c55e3cbcee..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 fda39335cb..d238a1af71 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 @@ -8,7 +8,7 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected", + "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected", "timeout": 120000 }, "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 237cc137ee..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 c8965186a1..255490eef3 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 @@ -8,7 +8,7 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected", + "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected", "timeout": 120000 }, "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 ec87336085..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 c3559ab0e0..43b50ad092 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json @@ -8,7 +8,7 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", + "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", "timeout": 120000 }, "cat package.json # check package.json has overrides field (not pnpm.overrides)", 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 23dada4743..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 @@ -> node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 fb0e465c64..5c445f62a3 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json @@ -8,7 +8,7 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- vp migrate --no-interactive --no-hooks --package-manager pnpm # migration should work with pnpm, write overrides and peerDependencyRules to pnpm-workspace.yaml", + "command": "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", "timeout": 120000 }, "cat package.json # check package.json has no pnpm section", 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 17306fb3c2..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_SHARED_DIR/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_SHARED_DIR/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 && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 3e84257133..14079647e8 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-bun/steps.json @@ -8,16 +8,16 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/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)", + "command": "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)", "timeout": 120000 }, "cat approved-app/package.json # core-js recorded under trustedDependencies", { - "command": "node $SNAP_SHARED_DIR/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", + "command": "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", "timeout": 120000 }, "cat default-app/package.json # no trustedDependencies, the build was not run", - "cd default-app && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 975e02b362..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_SHARED_DIR/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_SHARED_DIR/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 && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 558a03b00e..d4d8c9b386 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 @@ -7,16 +7,16 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/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", + "command": "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", "timeout": 120000 }, "cat approved-app/pnpm-workspace.yaml # approval recorded under allowBuilds despite the migration pre-install", { - "command": "node $SNAP_SHARED_DIR/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", + "command": "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", "timeout": 120000 }, "cat default-app/pnpm-workspace.yaml # no allowBuilds, the build was not run", - "cd default-app && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 04ab187c4e..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_SHARED_DIR/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_SHARED_DIR/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 && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 b60e21b419..6c4c0dc82a 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json @@ -7,16 +7,16 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/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)", + "command": "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)", "timeout": 120000 }, "cat approved-app/pnpm-workspace.yaml # approval recorded under allowBuilds", { - "command": "node $SNAP_SHARED_DIR/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", + "command": "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", "timeout": 120000 }, "cat default-app/pnpm-workspace.yaml # no allowBuilds, the build was not run", - "cd default-app && node $SNAP_SHARED_DIR/mock-npm-registry.mjs -- 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 b6f29e9373..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_SHARED_DIR/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_SHARED_DIR/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 11bc4092fe..ec8ff1dcf5 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-yarn/steps.json @@ -7,12 +7,12 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/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", + "command": "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", "timeout": 120000 }, "cat approved-app/package.json # core-js recorded under dependenciesMeta.built", { - "command": "node $SNAP_SHARED_DIR/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", + "command": "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", "timeout": 120000 }, "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 44cfef9520..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_SHARED_DIR/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 7346e32977..d348550ebc 100644 --- a/packages/cli/snap-tests/migration-standalone-bun-install/steps.json +++ b/packages/cli/snap-tests/migration-standalone-bun-install/steps.json @@ -7,7 +7,7 @@ }, "commands": [ { - "command": "node $SNAP_SHARED_DIR/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`", + "command": "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`", "timeout": 120000 }, "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/snap-tests/.shared/mock-npm-registry.mjs b/packages/tools/src/local-npm-registry.mjs similarity index 50% rename from packages/cli/snap-tests/.shared/mock-npm-registry.mjs rename to packages/tools/src/local-npm-registry.mjs index 9289cb811d..0ab3b998f9 100644 --- a/packages/cli/snap-tests/.shared/mock-npm-registry.mjs +++ b/packages/tools/src/local-npm-registry.mjs @@ -1,28 +1,122 @@ -// Minimal mock npm registry used by snap-tests that need install-time control -// over specific packages (`create-org-*`, and every fixture that installs the -// locally packed Vite+ packages). +// Local npm registry for testing Vite+ installs against the checkout. // -// Reads `./mock-manifest.json` if present (keyed by URL path, e.g. -// `"@your-org/create"`) and optionally serves `.tgz` tarballs from -// `./tarballs/`. When `SNAP_LOCAL_VP_PACKAGES_DIR` is set (see -// `localVitePlusPackages` in the snap-test harness), every tarball in that -// directory is served as a single-version packument, so package managers can -// install the checkout's own `vite-plus` / `@voidzero-dev/vite-plus-core` -// even when that version is not published on npm. Picks an ephemeral port, -// points the package managers of the child environment at it, spawns the -// wrapped command, and tears down when the child exits. +// 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. // -// Usage: node mock-server.mjs -- [args...] +// 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.mjs --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.mjs --pack --serve +// +// Usage: +// node local-npm-registry.mjs [--packages-dir ] [--pack] [--serve] [-- [args...]] +// +// --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 -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; import { Agent as HttpsAgent, get as httpsGet } from 'node:https'; import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { gunzipSync } from 'node:zlib'; +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; +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 { + console.error(`local-npm-registry: unknown option ${flags[i]}`); + process.exit(2); + } +} +if (!serve && command.length === 0) { + console.error( + 'usage: node local-npm-registry.mjs [--packages-dir ] [--pack] [--serve] [-- [args...]]', + ); + 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)), '..', '..', '..'); + +// Pack the checkout's publishable Vite+ packages so they can be served at the +// checkout version. Mirrors the snap harness's `packLocalVitePlusPackages`, +// for standalone (dev / e2e) invocations that don't run under the harness. +function packLocalVitePlusPackages() { + 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.`, + ); + } + } + 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.', + ); + } + const destination = mkdtempSync(path.join(tmpdir(), 'vp-local-registry-pack-')); + const result = spawnSync( + 'pnpm', + [ + '--filter', + 'vite-plus', + '--filter', + '@voidzero-dev/vite-plus-core', + 'pack', + '--pack-destination', + destination, + ], + { cwd: repoRoot, stdio: ['ignore', 'ignore', 'inherit'], shell: process.platform === 'win32' }, + ); + if (result.status !== 0) { + throw new Error(`pnpm pack failed with exit code ${result.status}`); + } + return destination; +} + +if (pack && !packagesDir) { + packagesDir = packLocalVitePlusPackages(); +} + const manifest = existsSync('./mock-manifest.json') ? JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')) : {}; @@ -56,10 +150,9 @@ const upstreamAgent = new HttpsAgent({ keepAlive: true, maxSockets: 64 }); function readPackageJsonFromTarball(tgzBytes, sourcePath) { const tar = gunzipSync(tgzBytes); for (let offset = 0; offset + 512 <= tar.length; ) { - const name = tar - .subarray(offset, offset + 100) - .toString() - .replace(/\0[^]*$/, ''); + 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 } @@ -79,17 +172,16 @@ function readPackageJsonFromTarball(tgzBytes, sourcePath) { throw new Error(`package/package.json not found in ${sourcePath}`); } -const localPackagesDir = process.env.SNAP_LOCAL_VP_PACKAGES_DIR; const localTarballs = new Map(); // tarball basename -> absolute path // 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 (localPackagesDir) { - for (const basename of readdirSync(localPackagesDir)) { +if (packagesDir) { + for (const basename of readdirSync(packagesDir)) { if (!basename.endsWith('.tgz')) { continue; } - const tgzPath = path.join(localPackagesDir, basename); + const tgzPath = path.join(packagesDir, basename); const bytes = readFileSync(tgzPath); const pkg = readPackageJsonFromTarball(bytes, tgzPath); localTarballs.set(basename, tgzPath); @@ -182,7 +274,7 @@ function proxyToUpstream(req, res) { fetchUrl(`${UPSTREAM_REGISTRY}${req.url ?? '/'}`, 5); } -const server = createServer(async (req, res) => { +const server = createServer((req, res) => { const key = decodeURIComponent(req.url ?? '/').replace(/^\/+/, ''); if (Object.hasOwn(manifest, key)) { const address = server.address(); @@ -206,53 +298,80 @@ const server = createServer(async (req, res) => { } } // 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); + // 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) { + 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', + 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) { + rmSync(env.YARN_GLOBAL_FOLDER, { recursive: true, force: true }); + rmSync(env.BUN_INSTALL_CACHE_DIR, { recursive: true, force: true }); +} + server.listen(0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { - console.error('mock-server: failed to bind'); + console.error('local-npm-registry: 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)); + 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}` : ''}`, + ...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, ...args] = process.argv.slice(separatorIndex + 1); - // 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 invocation would reuse dead URLs or stale local - // package bytes. Give each invocation throwaway caches instead. - const yarnGlobalFolder = mkdtempSync(path.join(tmpdir(), 'vp-mock-registry-yarn-')); - const bunCacheDir = mkdtempSync(path.join(tmpdir(), 'vp-mock-registry-bun-')); - const child = spawn(cmd, args, { - env: { - ...process.env, - // Every package manager reads its own env 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. - 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', - YARN_GLOBAL_FOLDER: yarnGlobalFolder, - BUN_INSTALL_CACHE_DIR: bunCacheDir, - }, + + const [cmd, ...cmdArgs] = command; + const child = spawn(cmd, cmdArgs, { + env: { ...process.env, ...registryEnv }, stdio: 'inherit', }); child.on('exit', (code, signal) => { - rmSync(yarnGlobalFolder, { recursive: true, force: true }); - rmSync(bunCacheDir, { recursive: true, force: true }); + cleanupRegistryEnv(registryEnv); const exitCode = code ?? (signal ? 128 : 0); server.close(() => process.exit(exitCode)); }); diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index f17da26449..4f6d28b0cd 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -21,10 +21,10 @@ 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 `.shared/mock-npm-registry.mjs` 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. + * through `local-npm-registry.mjs` 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. */ function packLocalVitePlusPackages(casesDir: string, tempTmpDir: string): Promise { localVpPackagesPromise ??= (async () => { @@ -558,10 +558,9 @@ interface Steps { * 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 - * `$SNAP_SHARED_DIR/mock-npm-registry.mjs` 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). + * `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). */ localVitePlusPackages?: boolean; /** @@ -684,10 +683,15 @@ async function runTestCase( // shared helper scripts under `/.shared/` without // duplicating them into every fixture directory. SNAP_CASES_DIR: casesDir, - // Shared helper scripts live under `snap-tests/.shared/` only; expose the - // directory independently of the active casesDir so `snap-tests-global` - // fixtures can use them too. - SNAP_SHARED_DIR: path.join(path.dirname(casesDir), 'snap-tests', '.shared'), + // 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.mjs', + ), // 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 } : {}), From b4b4943b3513bbd24dc005d1768763ad3a48d551 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 2 Jul 2026 21:34:07 +0800 Subject: [PATCH 03/14] test: convert local-npm-registry to TypeScript and add process maintenance Convert the registry script to erasable-syntax TypeScript so it typechecks with the rest of packages/tools while still running directly with bare node (the repo's Node floor strips types natively), from any project directory and with no loader wiring. The tool bin gains a local-npm-registry subcommand, and the .d.mts stub for the untyped module import is gone. Add cross-platform --ps / --kill maintenance modes (ps on Unix, PowerShell Get-CimInstance on Windows, process.kill for termination): --ps lists registry processes left behind by interrupted runs, --kill stops them and removes their leftover temp caches. A process qualifies only when node is the executing binary of the script, so shells whose command string merely mentions it stay out of the kill list. Exposed as pnpm local-registry:ps and pnpm local-registry:kill. --- .github/workflows/e2e-test.yml | 2 +- AGENTS.md | 2 +- CONTRIBUTING.md | 7 +- ecosystem-ci/patch-project.ts | 6 +- ecosystem-ci/verify-install.ts | 4 +- package.json | 4 +- packages/tools/src/index.ts | 8 +- ...npm-registry.mjs => local-npm-registry.ts} | 139 ++++++++++++++---- packages/tools/src/snap-test.ts | 4 +- 9 files changed, 137 insertions(+), 39 deletions(-) rename packages/tools/src/{local-npm-registry.mjs => local-npm-registry.ts} (75%) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index f58fbf8a9d..473270d4ef 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -110,7 +110,7 @@ jobs: run: | mkdir -p tmp/tgz # patch-project.ts serves these tgz through a local npm registry - # (packages/tools/src/local-npm-registry.mjs), so `vp migrate` pins + # (packages/tools/src/local-npm-registry.ts), so `vp migrate` pins # and installs them like a real release. 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 diff --git a/AGENTS.md b/AGENTS.md index 7428ae8a37..a4576bc511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +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.mjs` 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`). +- **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 3a8efb93ec..918f74cfad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,15 +100,15 @@ Verify the link with `ls -l node_modules/vite-plus` (it should be a symlink into ### 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.mjs` 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. +`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.mjs --pack -- vp migrate --no-interactive -node /path/to/vite-plus/packages/tools/src/local-npm-registry.mjs --pack -- vp create vite:application --no-interactive +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 @@ -119,6 +119,7 @@ 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 diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index c96d761271..3b1cd7ec72 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -24,8 +24,8 @@ const cwd = directory ? join(repoRoot, directory) : repoRoot; 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.mjs), so vp migrate pins and installs the checkout's -// own version through the standard registry code paths — no `file:` specs. +// (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. // The e2e build job pins packages/cli to 0.0.0 before `pnpm pack`; a local // run can serve whatever version the checkout carries. const vitePlusVersion = readdirSync(tgzDir) @@ -42,7 +42,7 @@ const registryScript = join( 'packages', 'tools', 'src', - 'local-npm-registry.mjs', + '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 diff --git a/ecosystem-ci/verify-install.ts b/ecosystem-ci/verify-install.ts index 1f2601f212..0de7ec8cf6 100644 --- a/ecosystem-ci/verify-install.ts +++ b/ecosystem-ci/verify-install.ts @@ -6,7 +6,7 @@ const require = createRequire(`${process.cwd()}/`); // The ecosystem-ci pack step pins packages/cli to 0.0.0 before `pnpm pack`, // and patch-project.ts serves that build through the local registry, so a -// correctly installed local build always reports 0.0.0 — never a published +// correctly installed local build always reports 0.0.0, never a published // registry version. A local (non-CI) run serves whatever version the checkout // carries; pass it via VP_EXPECTED_VERSION. const expectedVersion = process.env.VP_EXPECTED_VERSION ?? '0.0.0'; @@ -33,7 +33,7 @@ try { projectPkg.dependencies?.['vite-plus'] ?? projectPkg.devDependencies?.['vite-plus']; // The migration must pin the local version as a plain registry spec - // (resolved through the local registry), exactly like a real migration — + // (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 diff --git a/package.json b/package.json index c5672648e0..2777dac197 100644 --- a/package.json +++ b/package.json @@ -8,7 +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.mjs", + "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/tools/src/index.ts b/packages/tools/src/index.ts index d8847ff5fa..974aff8f1a 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -33,10 +33,16 @@ switch (subcommand) { const { repackViteTgz } = await import('./repack-vite-tgz.ts'); await repackViteTgz(); break; + case 'local-npm-registry': + // The standalone script parses process.argv.slice(2); drop the subcommand + // so `tool local-npm-registry --ps` behaves like invoking it with `node`. + process.argv.splice(2, 1); + await import('./local-npm-registry.ts'); + 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, repack-vite-tgz, local-npm-registry', ); process.exit(1); } diff --git a/packages/tools/src/local-npm-registry.mjs b/packages/tools/src/local-npm-registry.ts similarity index 75% rename from packages/tools/src/local-npm-registry.mjs rename to packages/tools/src/local-npm-registry.ts index 0ab3b998f9..3a85cd6d39 100644 --- a/packages/tools/src/local-npm-registry.mjs +++ b/packages/tools/src/local-npm-registry.ts @@ -6,6 +6,8 @@ // 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 @@ -17,12 +19,13 @@ // 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.mjs --pack -- vp migrate --no-interactive +// 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.mjs --pack --serve +// node /packages/tools/src/local-npm-registry.ts --pack --serve // // Usage: -// node local-npm-registry.mjs [--packages-dir ] [--pack] [--serve] [-- [args...]] +// 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) @@ -30,17 +33,30 @@ // @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 { createServer } from 'node:http'; +import { + createServer, + type IncomingMessage, + type OutgoingHttpHeaders, + type ServerResponse, +} from 'node:http'; import { Agent as HttpsAgent, get as httpsGet } from 'node:https'; import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { gunzipSync } from 'node:zlib'; +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); @@ -49,6 +65,8 @@ 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]; @@ -56,14 +74,83 @@ for (let i = 0; i < flags.length; i++) { 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.mjs [--packages-dir ] [--pack] [--serve] [-- [args...]]', + 'usage: node local-npm-registry.ts [--packages-dir ] [--pack] [--serve] [-- [args...]] | --ps | --kill', ); process.exit(2); } @@ -74,7 +161,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..' // Pack the checkout's publishable Vite+ packages so they can be served at the // checkout version. Mirrors the snap harness's `packLocalVitePlusPackages`, // for standalone (dev / e2e) invocations that don't run under the harness. -function packLocalVitePlusPackages() { +function packLocalVitePlusPackages(): string { for (const sentinel of ['cli/dist/bin.js', 'core/dist']) { if (!existsSync(path.join(repoRoot, 'packages', sentinel))) { throw new Error( @@ -117,14 +204,14 @@ if (pack && !packagesDir) { packagesDir = packLocalVitePlusPackages(); } -const manifest = existsSync('./mock-manifest.json') - ? JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')) +const manifest: Record = existsSync('./mock-manifest.json') + ? (JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')) as Record) : {}; // Proxy through the user's configured registry (e.g. a local mirror in // `~/.npmrc`) when there is one, so local runs stay as fast as direct // installs. CI has no user registry config and uses npmjs. -function resolveUpstreamRegistry() { +function resolveUpstreamRegistry(): string { try { const npmrc = readFileSync(path.join(homedir(), '.npmrc'), 'utf-8'); const registryLine = npmrc.split('\n').find((line) => line.trim().startsWith('registry=')); @@ -147,7 +234,7 @@ 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, sourcePath) { +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(); @@ -165,14 +252,16 @@ function readPackageJsonFromTarball(tgzBytes, sourcePath) { 8, ) || 0; if (name === 'package/package.json') { - return JSON.parse(tar.subarray(offset + 512, offset + 512 + size).toString()); + 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}`); } -const localTarballs = new Map(); // tarball basename -> absolute path +const localTarballs = new Map(); // tarball basename -> absolute path // 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'; @@ -209,7 +298,7 @@ if (packagesDir) { } } -function rewriteRegistry(value, registry) { +function rewriteRegistry(value: unknown, registry: string): unknown { return JSON.parse(JSON.stringify(value).replaceAll('{REGISTRY}', registry)); } @@ -219,24 +308,24 @@ function rewriteRegistry(value, registry) { // 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) { +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) => { + const fail = (error: Error) => { if (!res.headersSent) { res.writeHead(502); } res.end(`proxy error: ${error.message}`); }; - const fetchUrl = (url, redirectsLeft) => { + 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 + // 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 = { + const headers: OutgoingHttpHeaders = { accept: req.headers.accept ?? 'application/json', 'accept-encoding': req.url?.endsWith('.tgz') ? 'identity' @@ -252,18 +341,18 @@ function proxyToUpstream(req, res) { fetchUrl(new URL(upstream.headers.location, url).toString(), redirectsLeft - 1); return; } - const headers = {}; + 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. for (const name of ['content-type', 'content-encoding', 'content-length', 'location']) { if (upstream.headers[name] !== undefined) { - headers[name] = upstream.headers[name]; + responseHeaders[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); + 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. @@ -314,7 +403,7 @@ const server = createServer((req, res) => { // 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) { +function buildRegistryEnv(registry: string): Record { return { NPM_CONFIG_REGISTRY: registry, npm_config_registry: registry, @@ -326,7 +415,7 @@ function buildRegistryEnv(registry) { }; } -function cleanupRegistryEnv(env) { +function cleanupRegistryEnv(env: Record): void { rmSync(env.YARN_GLOBAL_FOLDER, { recursive: true, force: true }); rmSync(env.BUN_INSTALL_CACHE_DIR, { recursive: true, force: true }); } @@ -365,7 +454,7 @@ server.listen(0, '127.0.0.1', () => { return; } - const [cmd, ...cmdArgs] = command; + const [cmd, ...cmdArgs] = command as [string, ...string[]]; const child = spawn(cmd, cmdArgs, { env: { ...process.env, ...registryEnv }, stdio: 'inherit', diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index 4f6d28b0cd..28b4210408 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -21,7 +21,7 @@ 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.mjs` serve these tarballs at the checkout + * 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. @@ -690,7 +690,7 @@ async function runTestCase( 'packages', 'tools', 'src', - 'local-npm-registry.mjs', + '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. From 934f0cd8853c8c606f6de97cce7bb8cd41bfdd92 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 2 Jul 2026 22:05:04 +0800 Subject: [PATCH 04/14] test: merge local packuments into the upstream ones The dify e2e job exposed a gap in the single-version packuments: ecosystem projects that already reference a PUBLISHED vite-plus version in their committed lockfile could not verify those entries against the replaced metadata (pnpm's time-based resolution policy failed with ERR_PNPM_TRUST_DOWNGRADE: Missing time for version 0.2.1). Serve local packages as an overlay on the real upstream packument instead, 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 so local builds always take precedence), and latest points at it. Falls back to the local-only packument when upstream is unreachable or the package has never been published. --- packages/tools/src/local-npm-registry.ts | 83 ++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index 3a85cd6d39..ac1fc1c7ff 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -261,7 +261,15 @@ function readPackageJsonFromTarball(tgzBytes: Buffer, sourcePath: string): Packa 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'; @@ -274,7 +282,7 @@ if (packagesDir) { const bytes = readFileSync(tgzPath); const pkg = readPackageJsonFromTarball(bytes, tgzPath); localTarballs.set(basename, tgzPath); - manifest[pkg.name] = { + localPackuments.set(pkg.name, { name: pkg.name, 'dist-tags': { latest: pkg.version }, versions: { @@ -294,7 +302,7 @@ if (packagesDir) { modified: LOCAL_PACKAGE_TIME, [pkg.version]: LOCAL_PACKAGE_TIME, }, - }; + }); } } @@ -302,6 +310,68 @@ function rewriteRegistry(value: unknown, registry: string): unknown { return JSON.parse(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 @@ -363,14 +433,15 @@ function proxyToUpstream(req: IncomingMessage, res: ServerResponse): void { fetchUrl(`${UPSTREAM_REGISTRY}${req.url ?? '/'}`, 5); } -const server = createServer((req, res) => { +const server = createServer(async (req, res) => { const key = decodeURIComponent(req.url ?? '/').replace(/^\/+/, ''); - if (Object.hasOwn(manifest, key)) { + 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(JSON.stringify(rewriteRegistry(manifest[key], registry))); + res.end(JSON.stringify(rewriteRegistry(packument, registry))); return; } const tarMatch = key.match(/\/-\/([^/]+\.tgz)$/); @@ -437,7 +508,7 @@ server.listen(0, '127.0.0.1', () => { JSON.stringify({ registry, env: registryEnv }), '', `# serving local packages${packagesDir ? ` from ${packagesDir}` : ''}`, - ...Object.keys(manifest).map((name) => `# ${name}`), + ...[...localPackuments.keys(), ...Object.keys(manifest)].map((name) => `# ${name}`), '# run commands against it with:', ...Object.entries(registryEnv).map(([key, value]) => `export ${key}=${value}`), ]; From 858412c5b43416dd52c3ee8a646255bad235c375 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 2 Jul 2026 22:16:23 +0800 Subject: [PATCH 05/14] test(e2e): keep the release-age gate env away from dify The gate env (any spelling, even 0) combined with dify's resolutionMode: time-based activates pnpm's resolution-policy engine, which vp's bundled pnpm cannot handle (ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, no handleResolutionPolicyViolations callback wired). This is the same crash the old flow tolerated by swallowing dify's whole migrate failure; with the gate env excluded (dify's minimumReleaseAge key is already stripped by the workspace patch) the policy stays inactive and the migrate auto-install can actually succeed. The packument-merge fix from the previous commit already got dify past lockfile verification and the ESLint migration. --- ecosystem-ci/patch-project.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 3b1cd7ec72..3ac39baf2d 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -165,10 +165,21 @@ const vitestOverrides = { // 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. -const releaseAgeEnv = { - pnpm_config_minimum_release_age: '0', - PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0', -}; +// +// dify is the exception: it sets `resolutionMode: time-based`, and defining a +// minimumReleaseAge (even 0, via any env spelling) activates pnpm's +// resolution-policy engine, which vp's bundled pnpm cannot handle +// (ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, no +// handleResolutionPolicyViolations callback). Its `minimumReleaseAge:` key +// was already removed above, so with no gate env the policy stays inactive +// and installs work. +const releaseAgeEnv = + project === 'dify' + ? {} + : { + pnpm_config_minimum_release_age: '0', + PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0', + }; const migrateEnv: NodeJS.ProcessEnv = { ...process.env, From d0cbcb51f8a657a2a6e3d182244480e0449329ab Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 2 Jul 2026 23:28:31 +0800 Subject: [PATCH 06/14] test: apply cleanup review to the local registry change - extract the shared pack preflight+invocation into packages/tools/src/pack-local-vite-plus.ts, used by both the snap harness and local-npm-registry.ts --pack (was two hand-synced copies) - localVitePlusPackages fixtures get a 120s default command timeout in the harness instead of a pasted "timeout": 120000 on every wrapped command; steps.json env is optional now and empty env blocks are gone - drop the dead SNAP_CASES_DIR env (its last consumer left with .shared/) - serve packuments without a parse/stringify round trip and read local tarballs asynchronously so large responses do not stall in-flight proxied requests - tool local-npm-registry spawns the script by path instead of importing it under a spliced argv, so tool-launched servers match --ps/--kill - key the ecosystem release-age gate exception on resolutionMode: time-based in the project's workspace yaml instead of the dify project name - derive the expected e2e version from the packed tgz (vitePlusTgzVersion in ecosystem-ci/paths.ts) for both patch-project and verify-install, dropping the unused VP_EXPECTED_VERSION knob and the hardcoded 0.0.0 - document why the registry's upstream resolution deliberately ignores registry env vars --- .github/workflows/e2e-test.yml | 15 ++-- ecosystem-ci/patch-project.ts | 45 +++++------- ecosystem-ci/paths.ts | 17 +++++ ecosystem-ci/verify-install.ts | 13 ++-- .../create-framework-shim-astro/steps.json | 7 +- .../create-framework-shim-vue/steps.json | 15 ++-- .../steps.json | 5 +- .../migration-framework-shim-astro/steps.json | 5 +- .../migration-framework-shim-vue/steps.json | 5 +- .../migration-standalone-npm/steps.json | 5 +- .../migration-standalone-pnpm/steps.json | 5 +- .../create-approve-builds-bun/steps.json | 10 +-- .../steps.json | 10 +-- .../create-approve-builds-pnpm11/steps.json | 10 +-- .../create-approve-builds-yarn/steps.json | 10 +-- .../steps.json | 5 +- packages/tools/src/index.ts | 14 +++- packages/tools/src/local-npm-registry.ts | 65 +++++------------ packages/tools/src/pack-local-vite-plus.ts | 61 ++++++++++++++++ packages/tools/src/snap-test.ts | 73 +++---------------- 20 files changed, 176 insertions(+), 219 deletions(-) create mode 100644 packages/tools/src/pack-local-vite-plus.ts diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 473270d4ef..eb76661e86 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -111,14 +111,13 @@ jobs: mkdir -p tmp/tgz # 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. 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. The bun-only vite alias tgz that the old file:-override - # flow needed (bun peer-dep strictness, oven-sh/bun#8406) is gone: - # through the registry, bun installs the standard - # `vite -> npm:@voidzero-dev/vite-plus-core@` alias. + # 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 ../.. diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 3ac39baf2d..226f59a212 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -1,10 +1,10 @@ import { execSync, spawn } from 'node:child_process'; -import { readdirSync } from 'node:fs'; +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); @@ -26,15 +26,7 @@ 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. -// The e2e build job pins packages/cli to 0.0.0 before `pnpm pack`; a local -// run can serve whatever version the checkout carries. -const vitePlusVersion = readdirSync(tgzDir) - .map((entry) => /^vite-plus-(\d.*)\.tgz$/.exec(entry)?.[1]) - .find(Boolean); -if (!vitePlusVersion) { - console.error(`No vite-plus-.tgz found in ${tgzDir}`); - process.exit(1); -} +const vitePlusVersion = vitePlusTgzVersion(); const registryScript = join( import.meta.dirname, @@ -166,20 +158,23 @@ const vitestOverrides = { // 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. // -// dify is the exception: it sets `resolutionMode: time-based`, and defining a -// minimumReleaseAge (even 0, via any env spelling) activates pnpm's -// resolution-policy engine, which vp's bundled pnpm cannot handle -// (ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED, no -// handleResolutionPolicyViolations callback). Its `minimumReleaseAge:` key -// was already removed above, so with no gate env the policy stays inactive -// and installs work. -const releaseAgeEnv = - project === 'dify' - ? {} - : { - pnpm_config_minimum_release_age: '0', - PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0', - }; +// 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, 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 0de7ec8cf6..51a22924f5 100644 --- a/ecosystem-ci/verify-install.ts +++ b/ecosystem-ci/verify-install.ts @@ -2,14 +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`, -// and patch-project.ts serves that build through the local registry, so a -// correctly installed local build always reports 0.0.0, never a published -// registry version. A local (non-CI) run serves whatever version the checkout -// carries; pass it via VP_EXPECTED_VERSION. -const expectedVersion = process.env.VP_EXPECTED_VERSION ?? '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'); 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 a937e44c2b..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,18 +1,15 @@ { "ignoredPlatforms": ["win32"], "localVitePlusPackages": true, - "env": {}, "commands": [ { "command": "node $SNAP_LOCAL_REGISTRY -- vp create astro --no-interactive -- my-astro-app --yes --skip-houston --template basics # create Astro app", - "ignoreOutput": true, - "timeout": 120000 + "ignoreOutput": true }, "cat my-astro-app/src/env.d.ts # check Astro shim", { "command": "cd my-astro-app && node $SNAP_LOCAL_REGISTRY -- vp install --ignore-scripts -- --no-frozen-lockfile # install dependencies", - "ignoreOutput": true, - "timeout": 120000 + "ignoreOutput": true }, { "command": "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" 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 b3f5c39f4c..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,19 +1,22 @@ { - "ignoredPlatforms": ["win32", { "os": "linux", "libc": "musl" }], + "ignoredPlatforms": [ + "win32", + { + "os": "linux", + "libc": "musl" + } + ], "linkCheckoutPackages": true, "localVitePlusPackages": true, - "env": {}, "commands": [ { "command": "node $SNAP_LOCAL_REGISTRY -- vp create vite:application --no-interactive -- --template vue-ts # create Vue+TS app", - "ignoreOutput": true, - "timeout": 120000 + "ignoreOutput": true }, "cat vite-plus-application/src/env.d.ts # check Vue shim was added", { "command": "cd vite-plus-application && node $SNAP_LOCAL_REGISTRY -- vp install # install dependencies", - "ignoreOutput": true, - "timeout": 120000 + "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/steps.json b/packages/cli/snap-tests-global/migration-framework-shim-astro-vue/steps.json index b073c1d2f0..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 @@ -7,10 +7,7 @@ "VP_SKIP_INSTALL": "" }, "commands": [ - { - "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add both Vue and Astro shims", - "timeout": 120000 - }, + "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/steps.json b/packages/cli/snap-tests-global/migration-framework-shim-astro/steps.json index d238a1af71..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 @@ -7,10 +7,7 @@ "VP_SKIP_INSTALL": "" }, "commands": [ - { - "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Astro shim when astro dependency is detected", - "timeout": 120000 - }, + "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/steps.json b/packages/cli/snap-tests-global/migration-framework-shim-vue/steps.json index 255490eef3..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 @@ -7,10 +7,7 @@ "VP_SKIP_INSTALL": "" }, "commands": [ - { - "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should add Vue shim when vue dependency is detected", - "timeout": 120000 - }, + "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/steps.json b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json index 43b50ad092..d82e6c6a27 100644 --- a/packages/cli/snap-tests-global/migration-standalone-npm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-npm/steps.json @@ -7,10 +7,7 @@ "VP_SKIP_INSTALL": "" }, "commands": [ - { - "command": "node $SNAP_LOCAL_REGISTRY -- vp migrate --no-interactive --no-hooks # migration should work with npm, add overrides, and update lockfile", - "timeout": 120000 - }, + "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/steps.json b/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json index 5c445f62a3..28072d2673 100644 --- a/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json +++ b/packages/cli/snap-tests-global/migration-standalone-pnpm/steps.json @@ -7,10 +7,7 @@ "VP_SKIP_INSTALL": "" }, "commands": [ - { - "command": "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", - "timeout": 120000 - }, + "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/create-approve-builds-bun/steps.json b/packages/cli/snap-tests/create-approve-builds-bun/steps.json index 14079647e8..c90eb5507d 100644 --- a/packages/cli/snap-tests/create-approve-builds-bun/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-bun/steps.json @@ -7,15 +7,9 @@ "ADBLOCK": "1" }, "commands": [ - { - "command": "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)", - "timeout": 120000 - }, + "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", - { - "command": "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", - "timeout": 120000 - }, + "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 && 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/steps.json b/packages/cli/snap-tests/create-approve-builds-migrate-pnpm11/steps.json index d4d8c9b386..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 @@ -6,15 +6,9 @@ "ADBLOCK": "1" }, "commands": [ - { - "command": "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", - "timeout": 120000 - }, + "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", - { - "command": "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", - "timeout": 120000 - }, + "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 && 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/steps.json b/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json index 6c4c0dc82a..9aa6cad5ee 100644 --- a/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-pnpm11/steps.json @@ -6,15 +6,9 @@ "ADBLOCK": "1" }, "commands": [ - { - "command": "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)", - "timeout": 120000 - }, + "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", - { - "command": "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", - "timeout": 120000 - }, + "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 && 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/steps.json b/packages/cli/snap-tests/create-approve-builds-yarn/steps.json index ec8ff1dcf5..f8374a4df7 100644 --- a/packages/cli/snap-tests/create-approve-builds-yarn/steps.json +++ b/packages/cli/snap-tests/create-approve-builds-yarn/steps.json @@ -6,15 +6,9 @@ "ADBLOCK": "1" }, "commands": [ - { - "command": "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", - "timeout": 120000 - }, + "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", - { - "command": "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", - "timeout": 120000 - }, + "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/migration-standalone-bun-install/steps.json b/packages/cli/snap-tests/migration-standalone-bun-install/steps.json index d348550ebc..da0bc482eb 100644 --- a/packages/cli/snap-tests/migration-standalone-bun-install/steps.json +++ b/packages/cli/snap-tests/migration-standalone-bun-install/steps.json @@ -6,10 +6,7 @@ "CI": "" }, "commands": [ - { - "command": "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`", - "timeout": 120000 - }, + "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/tools/src/index.ts b/packages/tools/src/index.ts index 974aff8f1a..f36fee58c6 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -34,10 +34,16 @@ switch (subcommand) { await repackViteTgz(); break; case 'local-npm-registry': - // The standalone script parses process.argv.slice(2); drop the subcommand - // so `tool local-npm-registry --ps` behaves like invoking it with `node`. - process.argv.splice(2, 1); - await import('./local-npm-registry.ts'); + // 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}`); diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index ac1fc1c7ff..a514b9c983 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -39,6 +39,7 @@ 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, @@ -51,6 +52,8 @@ 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; @@ -158,50 +161,9 @@ if (!serve && command.length === 0) { // The script lives at `packages/tools/src/`, so the repo root is three up. const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); -// Pack the checkout's publishable Vite+ packages so they can be served at the -// checkout version. Mirrors the snap harness's `packLocalVitePlusPackages`, -// for standalone (dev / e2e) invocations that don't run under the harness. -function packLocalVitePlusPackages(): string { - 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.`, - ); - } - } - 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.', - ); - } - const destination = mkdtempSync(path.join(tmpdir(), 'vp-local-registry-pack-')); - const result = spawnSync( - 'pnpm', - [ - '--filter', - 'vite-plus', - '--filter', - '@voidzero-dev/vite-plus-core', - 'pack', - '--pack-destination', - destination, - ], - { cwd: repoRoot, stdio: ['ignore', 'ignore', 'inherit'], shell: process.platform === 'win32' }, - ); - if (result.status !== 0) { - throw new Error(`pnpm pack failed with exit code ${result.status}`); - } - return destination; -} - if (pack && !packagesDir) { - packagesDir = packLocalVitePlusPackages(); + packagesDir = mkdtempSync(path.join(tmpdir(), 'vp-local-registry-pack-')); + await packLocalVitePlusPackages(repoRoot, packagesDir); } const manifest: Record = existsSync('./mock-manifest.json') @@ -210,7 +172,10 @@ const manifest: Record = existsSync('./mock-manifest.json') // Proxy through the user's configured registry (e.g. a local mirror in // `~/.npmrc`) when there is one, so local runs stay as fast as direct -// installs. CI has no user registry config and uses npmjs. +// installs. CI has no user registry config and uses npmjs. Deliberately +// reads only `~/.npmrc` 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. function resolveUpstreamRegistry(): string { try { const npmrc = readFileSync(path.join(homedir(), '.npmrc'), 'utf-8'); @@ -306,8 +271,10 @@ if (packagesDir) { } } -function rewriteRegistry(value: unknown, registry: string): unknown { - return JSON.parse(JSON.stringify(value).replaceAll('{REGISTRY}', registry)); +// 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 { @@ -441,13 +408,15 @@ const server = createServer(async (req, res) => { 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(JSON.stringify(rewriteRegistry(packument, registry))); + res.end(serializeForRegistry(packument, registry)); return; } const tarMatch = key.match(/\/-\/([^/]+\.tgz)$/); if (tarMatch) { try { - const bytes = readFileSync( + // 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' }); 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/snap-test.ts b/packages/tools/src/snap-test.ts index 28b4210408..05407650a1 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -1,21 +1,19 @@ -import { execFile } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import { cpus, homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { setTimeout } from 'node:timers/promises'; -import { debuglog, parseArgs, promisify } from 'node:util'; +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'); -const execFileAsync = promisify(execFile); - let localVpPackagesPromise: Promise | undefined; /** @@ -26,60 +24,13 @@ let localVpPackagesPromise: Promise | undefined; * published on npm (e.g. on release branches) and exercise the actual local * build. */ -function packLocalVitePlusPackages(casesDir: string, tempTmpDir: string): Promise { +function packLocalVitePlusPackagesOnce(casesDir: string, tempTmpDir: string): Promise { localVpPackagesPromise ??= (async () => { - const repoRoot = resolveRepoRoot(casesDir); - for (const sentinel of ['cli/dist/bin.js', 'core/dist']) { - const sentinelPath = path.join(repoRoot, 'packages', sentinel); - if (!fs.existsSync(sentinelPath)) { - throw new Error( - `Cannot pack local Vite+ packages: ${sentinelPath} is missing.\n` + - 'Run `pnpm build` before snap tests that install local Vite+ packages.', - ); - } - } - // `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 = fs - .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}.\n` + - 'Run `pnpm bootstrap-cli` (or build the NAPI binding) first.', - ); - } const destination = path.join(tempTmpDir, 'local-vite-plus-packages'); fs.mkdirSync(destination, { recursive: true }); const startTime = Date.now(); - 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 = fs.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'}`, - ); - } - console.log( - 'Packed local Vite+ packages in %dms: %s', - Date.now() - startTime, - packed.join(', '), - ); + await packLocalVitePlusPackages(resolveRepoRoot(casesDir), destination); + console.log('Packed local Vite+ packages in %dms', Date.now() - startTime); return destination; })(); return localVpPackagesPromise; @@ -552,7 +503,7 @@ 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 @@ -561,6 +512,8 @@ interface Steps { * `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; /** @@ -679,10 +632,6 @@ 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( @@ -702,7 +651,7 @@ async function runTestCase( }; if (steps.localVitePlusPackages) { - env['SNAP_LOCAL_VP_PACKAGES_DIR'] = await packLocalVitePlusPackages(casesDir, tempTmpDir); + env['SNAP_LOCAL_VP_PACKAGES_DIR'] = await packLocalVitePlusPackagesOnce(casesDir, tempTmpDir); } // Unset VP_NODE_VERSION to prevent `vp env use` session overrides @@ -764,7 +713,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(); From 2d807d55b5fe6eba58d0620567c01373444a43df Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 09:57:55 +0800 Subject: [PATCH 07/14] test: address registry proxy review comments - forward the original request method and body upstream so npm's POST endpoints (audit bulk advisories) work through the proxy; redirects are only followed for body-less methods since the body streams into the first request - resolve the upstream registry from the project .npmrc in cwd before the user ~/.npmrc, so projects on a custom registry keep resolving from it - remove the --pack temp directory in the normal cleanup paths (wrapper exit and --serve shutdown) instead of leaving it for --kill - document the deliberate limitations: registry env vars are ignored as upstream sources (stale exports from a previous --serve would become the upstream), and HTTP(S)_PROXY tunneling plus authenticated upstreams are out of scope for the environments this tool serves --- docs/.vitepress/config.mts | 2 +- .../.vitepress/theme/components/home/Hero.vue | 2 +- docs/guide/troubleshooting.md | 2 +- packages/tools/src/local-npm-registry.ts | 129 ++++++++++++------ 4 files changed, 89 insertions(+), 46 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index f8259f6366..1f2bf70b6e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -150,7 +150,7 @@ export default extendConfig( { text: 'Releases', link: 'https://github.com/voidzero-dev/vite-plus/releases' }, { text: 'Announcement', - link: 'https://voidzero.dev/posts/announcing-vite-plus-alpha', + link: 'https://voidzero.dev/posts/announcing-vite-plus-beta', }, { text: 'Contributing', diff --git a/docs/.vitepress/theme/components/home/Hero.vue b/docs/.vitepress/theme/components/home/Hero.vue index 2408283338..7eb22b811d 100644 --- a/docs/.vitepress/theme/components/home/Hero.vue +++ b/docs/.vitepress/theme/components/home/Hero.vue @@ -15,7 +15,7 @@
Get started = existsSync('./mock-manifest.json') ? (JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')) as Record) : {}; -// Proxy through the user's configured registry (e.g. a local mirror in -// `~/.npmrc`) when there is one, so local runs stay as fast as direct -// installs. CI has no user registry config and uses npmjs. Deliberately -// reads only `~/.npmrc` and NOT registry env vars (unlike the CLI's +// 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. +// `--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 { - try { - const npmrc = readFileSync(path.join(homedir(), '.npmrc'), '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; + 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 } - } catch { - // no user .npmrc: use the default registry } return 'https://registry.npmjs.org'; } @@ -354,6 +366,13 @@ function proxyToUpstream(req: IncomingMessage, res: ServerResponse): void { } 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 @@ -368,34 +387,55 @@ function proxyToUpstream(req: IncomingMessage, res: ServerResponse): void { ? 'identity' : (req.headers['accept-encoding'] ?? 'identity'), }; - httpsGet(url, { headers, agent: upstreamAgent }, (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; + for (const name of ['content-type', 'content-length'] as const) { + if (req.headers[name] !== undefined) { + headers[name] = req.headers[name]; } - 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. - for (const name of ['content-type', 'content-encoding', 'content-length', 'location']) { - if (upstream.headers[name] !== undefined) { - responseHeaders[name] = upstream.headers[name]; + } + 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; } - } - // 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); - }).on('error', fail); + 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. + for (const name of ['content-type', 'content-encoding', 'content-length', 'location']) { + if (upstream.headers[name] !== undefined) { + responseHeaders[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. + 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); } @@ -458,6 +498,9 @@ function buildRegistryEnv(registry: string): Record { 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', () => { From 00c00c2da6b5e128870cee4f8ee9c8c116ddc133 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 10:57:38 +0800 Subject: [PATCH 08/14] docs: revert unrelated Hero.vue edit The alpha to beta announcement link change in Hero.vue was swept into 2d807d55 from the working tree by accident and is not part of this PR. --- docs/.vitepress/theme/components/home/Hero.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/.vitepress/theme/components/home/Hero.vue b/docs/.vitepress/theme/components/home/Hero.vue index 7eb22b811d..d2b3b7bcd5 100644 --- a/docs/.vitepress/theme/components/home/Hero.vue +++ b/docs/.vitepress/theme/components/home/Hero.vue @@ -20,7 +20,7 @@ rel="noopener noreferrer" class="button" > - Read the Announcement + Read the Beta Announcement
From d7e1fc00cca7ff1170ba19f3540806f997fb9be8 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 10:59:38 +0800 Subject: [PATCH 09/14] docs: revert remaining unrelated docs edits Restore docs/.vitepress/config.mts, Hero.vue, and docs/guide/troubleshooting.md to the branch merge-base so this PR carries no docs changes (the earlier Hero.vue revert targeted main, which had moved ahead of the merge-base). --- docs/.vitepress/config.mts | 2 +- docs/.vitepress/theme/components/home/Hero.vue | 4 ++-- docs/guide/troubleshooting.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1f2bf70b6e..f8259f6366 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -150,7 +150,7 @@ export default extendConfig( { text: 'Releases', link: 'https://github.com/voidzero-dev/vite-plus/releases' }, { text: 'Announcement', - link: 'https://voidzero.dev/posts/announcing-vite-plus-beta', + link: 'https://voidzero.dev/posts/announcing-vite-plus-alpha', }, { text: 'Contributing', diff --git a/docs/.vitepress/theme/components/home/Hero.vue b/docs/.vitepress/theme/components/home/Hero.vue index d2b3b7bcd5..2408283338 100644 --- a/docs/.vitepress/theme/components/home/Hero.vue +++ b/docs/.vitepress/theme/components/home/Hero.vue @@ -15,12 +15,12 @@ diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index ed902eca72..464c600b00 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -3,7 +3,7 @@ Use this page when something in Vite+ is not behaving the way you expect. ::: warning -Vite+ is still in beta. We are making frequent changes, adding features quickly, and we want feedback to help make it great. +Vite+ is still in alpha. We are making frequent changes, adding features quickly, and we want feedback to help make it great. ::: ## Supported Tool Versions From 864a885bb642c82bcb5d8c64c681414bf92ae343 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 14:51:41 +0800 Subject: [PATCH 10/14] test(snap): document why local packages pack into the per-run temp dir A fresh pack per run keeps the served tarballs in sync with the current checkout build (a fixed reused directory would serve stale tarballs after a rebuild without an invalidation scheme), inherits the run's exit cleanup, and isolates concurrent snap-test processes. The localVitePlusPackages flag stays regardless of the directory choice: it gates the pack step and its build prerequisites to the fixtures that install local packages, and raises their default command timeout. --- packages/tools/src/snap-test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index 05407650a1..1f5651c4b0 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -23,6 +23,14 @@ let localVpPackagesPromise: Promise | undefined; * 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 () => { From e8d5f85860b70acf98c646ac15b77ff1d44e4172 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 16:08:24 +0800 Subject: [PATCH 11/14] test(create): install local builds through the local registry Migrate test-vp-create.yml off the file: override flow: the test jobs start a detached local registry serving the packed 0.0.0 build (env exported via GITHUB_ENV for all later steps), VP_VERSION becomes the plain semver, and the vite override uses the standard npm:@voidzero-dev/vite-plus-core alias for every package manager. The bun-only vite-7.99.0 alias tgz existed solely because bun's strict peer check rejects file: override targets; through a registry bun installs the standard alias (proven by the ecosystem e2e and snap fixtures), so the repack-vite-tgz tool loses its last consumer and is removed along with the tools package's nanotar dependency. This was the last test surface installing vite-plus via file: specs; snap fixtures, ecosystem e2e, and local dev iteration already go through packages/tools/src/local-npm-registry.ts. --- .github/workflows/test-vp-create.yml | 62 +++++++++----------- packages/tools/package.json | 3 +- packages/tools/src/index.ts | 6 +- packages/tools/src/repack-vite-tgz.ts | 81 --------------------------- pnpm-lock.yaml | 3 - 5 files changed, 30 insertions(+), 125 deletions(-) delete mode 100644 packages/tools/src/repack-vite-tgz.ts diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 2a2973cc30..9a787aa9ca 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,22 @@ 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 & + until head -1 "$RUNNER_TEMP/local-registry.out" 2>/dev/null | grep -q registry; do sleep 0.2; done + head -1 "$RUNNER_TEMP/local-registry.out" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{for(const [k,v] of Object.entries(JSON.parse(s).env)) console.log(k+'='+v)})" >> "$GITHUB_ENV" + head -1 "$RUNNER_TEMP/local-registry.out" + - 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"}' + VP_OVERRIDE_PACKAGES: '{"vite":"npm:@voidzero-dev/vite-plus-core@0.0.0","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 +328,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 " 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 f36fee58c6..3f77e19529 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -29,10 +29,6 @@ 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(); - break; 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 @@ -48,7 +44,7 @@ switch (subcommand) { 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, local-npm-registry', + '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/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/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:' From ca9ffe6fd4715d916d1a5bff8b94aa4408bf8955 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 16:13:41 +0800 Subject: [PATCH 12/14] test(create): warm up the yarn monorepo cache check like npm With registry-resolved semver specs Yarn Berry hoists the workspace deps like a real install (the old file: overrides forced per-package node_modules copies at install time), so packages/utils/node_modules is born during run 1 and the directory fingerprint misses on run 2, same as npm (#1638). Give yarn the same single warm-up run before asserting the 100% cache hit. --- .github/workflows/test-vp-create.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 9a787aa9ca..d7ddd79086 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -453,18 +453,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 From da3ec897825362d7e93de282ae7ebd0b895f7a28 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 16:38:07 +0800 Subject: [PATCH 13/14] test: apply cleanup review to the create-workflow migration - fail fast when the workflow's local registry server dies during startup (liveness check in the wait loop) instead of spinning until the job timeout, and read the handshake once with a simpler JSON one-liner - drop the step-level VP_OVERRIDE_PACKAGES from test-vp-create.yml: with VP_VERSION=0.0.0 the product default override map already pins the vite alias and the bundled vitest, and stays correct across vitest bumps without editing the workflow (fresh created projects have no committed lockfile, so the ecosystem-ci coverage-provider pins do not apply here) - dedupe the proxy's conditional header copying into copyHeaders - reword a migrator comment that still described the removed file: tgz override mode --- .github/workflows/test-vp-create.yml | 24 +++++++++++---- packages/cli/src/migration/migrator/shared.ts | 7 +++-- packages/tools/src/local-npm-registry.ts | 29 ++++++++++++------- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index d7ddd79086..aead0738d1 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -211,14 +211,26 @@ jobs: # 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 & - until head -1 "$RUNNER_TEMP/local-registry.out" 2>/dev/null | grep -q registry; do sleep 0.2; done - head -1 "$RUNNER_TEMP/local-registry.out" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{for(const [k,v] of Object.entries(JSON.parse(s).env)) console.log(k+'='+v)})" >> "$GITHUB_ENV" - head -1 "$RUNNER_TEMP/local-registry.out" - + 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":"npm:@voidzero-dev/vite-plus-core@0.0.0","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 \ 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/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index bd40008147..169b3e5a10 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -357,6 +357,18 @@ async function resolveLocalPackument(name: string): Promise { // 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. @@ -387,11 +399,7 @@ function proxyToUpstream(req: IncomingMessage, res: ServerResponse): void { ? 'identity' : (req.headers['accept-encoding'] ?? 'identity'), }; - for (const name of ['content-type', 'content-length'] as const) { - if (req.headers[name] !== undefined) { - headers[name] = req.headers[name]; - } - } + copyHeaders(req.headers, headers, ['content-type', 'content-length']); const upstreamRequest = httpsRequest( url, { method, headers, agent: upstreamAgent }, @@ -414,11 +422,12 @@ function proxyToUpstream(req: IncomingMessage, res: ServerResponse): void { 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. - for (const name of ['content-type', 'content-encoding', 'content-length', 'location']) { - if (upstream.headers[name] !== undefined) { - responseHeaders[name] = upstream.headers[name]; - } - } + 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'; From 5ec66b734ec2352a7f5ef14b31cad80af1249468 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Jul 2026 17:29:09 +0800 Subject: [PATCH 14/14] test: keep loopback registry traffic off HTTP proxies 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 the local server. Extend the no-proxy list (NO_PROXY/no_proxy and npm's noproxy spellings, merged with any existing value) in the registry env so loopback traffic always goes direct. --- packages/tools/src/local-npm-registry.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index 169b3e5a10..d369f95dc8 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -493,12 +493,23 @@ const server = createServer(async (req, res) => { // 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-')), };