From 734bffb2ded108be95158cf8334415c5afa563b1 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 16 Jul 2026 09:49:34 +0800 Subject: [PATCH 1/2] fix(migrate): support guarded package installs --- .../utils/__tests__/preview-registry.spec.ts | 59 +++++ .../utils/__tests__/run-vite-install.spec.ts | 32 ++- packages/cli/src/utils/preview-registry.ts | 232 +++++++++++++++++- packages/cli/src/utils/prompts.ts | 22 +- 4 files changed, 333 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/utils/__tests__/preview-registry.spec.ts b/packages/cli/src/utils/__tests__/preview-registry.spec.ts index cbaf401b12..1728fb73d5 100644 --- a/packages/cli/src/utils/__tests__/preview-registry.spec.ts +++ b/packages/cli/src/utils/__tests__/preview-registry.spec.ts @@ -60,6 +60,65 @@ describe('reconcilePreviewBridgeRegistry', () => { expect(fs.readFileSync(path.join(dir, '.npmrc'), 'utf8')).toContain(`registry=${BRIDGE}`); }); + it('exempts managed packages from an npm minimum release age gate', () => { + fs.writeFileSync( + path.join(dir, '.npmrc'), + 'min-release-age=3\nmin-release-age-exclude[]=user-package\n', + ); + reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.npm, '11.17.0'); + reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.npm, '11.17.0'); + const npmrc = fs.readFileSync(path.join(dir, '.npmrc'), 'utf8'); + expect(npmrc).toContain('min-release-age-exclude[]=user-package'); + for (const name of [ + 'vite-plus', + '@voidzero-dev/vite-plus-core', + '@voidzero-dev/vite-plus-darwin-x64', + 'vitest', + '@vitest/browser', + ]) { + expect( + npmrc.split(/\r?\n/).filter((line) => line === `min-release-age-exclude[]=${name}`), + ).toHaveLength(1); + } + }); + + it('exempts managed packages from a Bun minimum release age gate', () => { + fs.writeFileSync( + path.join(dir, 'bunfig.toml'), + '[install]\nminimumReleaseAge = 259200\nminimumReleaseAgeExcludes = ["@demo/*"]\n\n[run]\nsilent = true\n', + ); + reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.bun); + reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.bun); + const bunfig = fs.readFileSync(path.join(dir, 'bunfig.toml'), 'utf8'); + expect(bunfig).toContain('minimumReleaseAgeExcludes = ["@demo/*", "vite-plus"'); + expect(bunfig).toContain('"@voidzero-dev/vite-plus-core"'); + expect(bunfig).toContain('"@voidzero-dev/vite-plus-darwin-x64"'); + expect(bunfig).toContain('"vitest"'); + expect(bunfig).toContain('"@vitest/browser"'); + expect(bunfig.match(/"@vitest\/browser"/g)).toHaveLength(1); + expect(bunfig).toContain('[run]\nsilent = true'); + }); + + it('adds exact preview trust exclusions for pnpm and removes them for a real release', () => { + fs.writeFileSync( + path.join(dir, 'pnpm-workspace.yaml'), + "packages: []\ntrustPolicy: no-downgrade\ntrustPolicyExclude:\n - 'webpack@5.0.0'\n", + ); + reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.pnpm); + reconcilePreviewBridgeRegistry(dir, PREVIEW, PackageManager.pnpm); + let workspace = fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); + expect(workspace).toContain(`vite-plus@${PREVIEW}`); + expect(workspace).toContain(`@voidzero-dev/vite-plus-core@${PREVIEW}`); + expect(workspace).toContain(`@voidzero-dev/vite-plus-darwin-x64@${PREVIEW}`); + expect(workspace.match(new RegExp(`vite-plus@${PREVIEW}`, 'g'))).toHaveLength(1); + expect(workspace).toContain('webpack@5.0.0'); + + reconcilePreviewBridgeRegistry(dir, '0.2.1', PackageManager.pnpm); + workspace = fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); + expect(workspace).not.toContain('0.0.0-commit'); + expect(workspace).toContain('webpack@5.0.0'); + }); + it('appends to an existing .npmrc without clobbering it', () => { fs.writeFileSync(path.join(dir, '.npmrc'), '@scope:registry=https://npm.example.com/\n'); reconcilePreviewBridgeRegistry(dir, PREVIEW); diff --git a/packages/cli/src/utils/__tests__/run-vite-install.spec.ts b/packages/cli/src/utils/__tests__/run-vite-install.spec.ts index 087ade72c9..bd56f23396 100644 --- a/packages/cli/src/utils/__tests__/run-vite-install.spec.ts +++ b/packages/cli/src/utils/__tests__/run-vite-install.spec.ts @@ -1,3 +1,7 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PackageManager } from '../../types/index.ts'; @@ -15,10 +19,11 @@ function installResult(exitCode: number, stdout = '', stderr = '') { return { exitCode, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) }; } -describe('runViteInstall with detectIgnoredBuilds', () => { +describe('runViteInstall', () => { beforeEach(() => { mockRun.mockReset(); delete process.env.VP_SKIP_INSTALL; + delete process.env.VP_VERSION; }); it('treats pnpm >= 11 ERR_PNPM_IGNORED_BUILDS exit-1 as a completed install', async () => { @@ -77,4 +82,29 @@ describe('runViteInstall with detectIgnoredBuilds', () => { expect.objectContaining({ args: ['install', '--ignore-scripts'] }), ); }); + + it('temporarily relaxes an npm age gate when the pinned npm lacks exclusions', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vite-plus-install-policy-')); + try { + fs.writeFileSync(path.join(dir, '.npmrc'), 'min-release-age=3\n'); + mockRun.mockResolvedValue(installResult(0)); + + await runViteInstall(dir, false, undefined, { + silent: true, + packageManager: PackageManager.npm, + packageManagerVersion: '11.12.1', + }); + + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + envs: expect.objectContaining({ NPM_CONFIG_MIN_RELEASE_AGE: '0' }), + }), + ); + expect(fs.readFileSync(path.join(dir, '.npmrc'), 'utf8')).not.toContain( + 'min-release-age-exclude', + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/utils/preview-registry.ts b/packages/cli/src/utils/preview-registry.ts index fc5f9e9468..8ef1354671 100644 --- a/packages/cli/src/utils/preview-registry.ts +++ b/packages/cli/src/utils/preview-registry.ts @@ -1,12 +1,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { Scalar } from 'yaml'; +import semver from 'semver'; +import { Scalar, YAMLSeq } from 'yaml'; import { PackageManager } from '../types/index.ts'; import { VITE_PLUS_VERSION } from './constants.ts'; import { readJsonFile } from './json.ts'; -import { editYamlFile } from './yaml.ts'; +import { editYamlFile, scalarString } from './yaml.ts'; const DEFAULT_BRIDGE_REGISTRY = 'https://registry-bridge.viteplus.dev/'; const REGISTRY_MARKER = '# vite-plus preview build registry bridge (auto-added by vp)'; @@ -15,6 +16,41 @@ const REGISTRY_MARKER = '# vite-plus preview build registry bridge (auto-added b // pointed at the same host. const BRIDGE_HOST = 'registry-bridge.viteplus.dev'; +const PREVIEW_TRUST_PACKAGES = [ + 'vite-plus', + '@voidzero-dev/vite-plus-core', + '@voidzero-dev/vite-plus-darwin-arm64', + '@voidzero-dev/vite-plus-darwin-x64', + '@voidzero-dev/vite-plus-linux-arm64-gnu', + '@voidzero-dev/vite-plus-linux-arm64-musl', + '@voidzero-dev/vite-plus-linux-x64-gnu', + '@voidzero-dev/vite-plus-linux-x64-musl', + '@voidzero-dev/vite-plus-win32-arm64-msvc', + '@voidzero-dev/vite-plus-win32-x64-msvc', +] as const; +const MANAGED_VITEST_PACKAGES = [ + 'vitest', + '@vitest/browser', + '@vitest/browser-playwright', + '@vitest/browser-preview', + '@vitest/browser-webdriverio', + '@vitest/coverage-istanbul', + '@vitest/coverage-v8', + '@vitest/expect', + '@vitest/mocker', + '@vitest/pretty-format', + '@vitest/runner', + '@vitest/snapshot', + '@vitest/spy', + '@vitest/ui', + '@vitest/utils', + '@vitest/web-worker', + '@vitest/ws-client', +] as const; +const MANAGED_AGE_GATE_EXCLUDES = [...PREVIEW_TRUST_PACKAGES, ...MANAGED_VITEST_PACKAGES]; +const PREVIEW_TRUST_SELECTOR_RE = + /^(?:vite-plus|@voidzero-dev\/vite-plus(?:-core|-(?:darwin-(?:arm64|x64)|linux-(?:arm64|x64)-(?:gnu|musl)|win32-(?:arm64|x64)-msvc)))@0\.0\.0-/; + /** * Registry bridge that serves PR preview builds as ordinary `0.0.0-commit.` * versions (and proxies everything else to npmjs). Only ever used for preview @@ -84,6 +120,167 @@ function ensureNpmrcRegistry(projectRoot: string): void { fs.appendFileSync(npmrc, `${prefix}${REGISTRY_MARKER}\nregistry=${bridge}\n`); } +function npmSupportsMinimumReleaseAgeExclude(packageManagerVersion: string | undefined): boolean { + const coerced = packageManagerVersion ? semver.coerce(packageManagerVersion)?.version : undefined; + return coerced !== undefined && semver.gte(coerced, '11.17.0'); +} + +function hasNpmMinimumReleaseAge(projectRoot: string): boolean { + const npmrc = path.join(projectRoot, '.npmrc'); + if (!fs.existsSync(npmrc)) { + return false; + } + return /^\s*min-release-age\s*=/m.test(fs.readFileSync(npmrc, 'utf8')); +} + +function ensureNpmMinimumReleaseAgeExcludes( + projectRoot: string, + packageManagerVersion: string | undefined, +): boolean { + if ( + !npmSupportsMinimumReleaseAgeExclude(packageManagerVersion) || + !hasNpmMinimumReleaseAge(projectRoot) + ) { + return false; + } + const npmrc = path.join(projectRoot, '.npmrc'); + const original = fs.readFileSync(npmrc, 'utf8'); + const existing = new Set( + [...original.matchAll(/^\s*min-release-age-exclude(?:\[\])?\s*=\s*(.+?)\s*$/gm)].map((match) => + match[1].replace(/^(['"])(.*)\1$/, '$2'), + ), + ); + const missing = MANAGED_AGE_GATE_EXCLUDES.filter((name) => !existing.has(name)); + if (missing.length === 0) { + return false; + } + const prefix = original.length > 0 && !original.endsWith('\n') ? '\n' : ''; + fs.appendFileSync( + npmrc, + `${prefix}${missing.map((name) => `min-release-age-exclude[]=${name}`).join('\n')}\n`, + ); + return true; +} + +function ensureBunMinimumReleaseAgeExcludes(projectRoot: string): boolean { + const bunfigPath = path.join(projectRoot, 'bunfig.toml'); + if (!fs.existsSync(bunfigPath)) { + return false; + } + const original = fs.readFileSync(bunfigPath, 'utf8'); + const installHeader = /^\s*\[install\]\s*(?:#.*)?$/m.exec(original); + if (!installHeader) { + return false; + } + const sectionStart = installHeader.index + installHeader[0].length; + const nextSection = /^\s*\[[^\]]+\]\s*(?:#.*)?$/m.exec(original.slice(sectionStart)); + const sectionEnd = nextSection ? sectionStart + nextSection.index : original.length; + const section = original.slice(sectionStart, sectionEnd); + const ageMatch = /^([ \t]*)minimumReleaseAge[ \t]*=.*$/m.exec(section); + if (!ageMatch) { + return false; + } + + const excludesRe = /^([ \t]*minimumReleaseAgeExcludes[ \t]*=[ \t]*)\[([\s\S]*?)\]([^\r\n]*)$/m; + const excludesMatch = excludesRe.exec(section); + const existing = new Set(); + if (excludesMatch) { + for (const match of excludesMatch[2].matchAll(/(['"])(.*?)\1/g)) { + existing.add(match[2]); + } + } + const missing = MANAGED_AGE_GATE_EXCLUDES.filter((name) => !existing.has(name)); + if (missing.length === 0) { + return false; + } + + let nextSectionContent: string; + if (excludesMatch) { + const body = excludesMatch[2]; + const trailingWhitespace = body.match(/\s*$/)?.[0] ?? ''; + const content = body.slice(0, body.length - trailingWhitespace.length); + const separator = content.trim().length === 0 ? '' : content.trimEnd().endsWith(',') ? '' : ','; + const addition = missing.map((name) => JSON.stringify(name)).join(', '); + const spacing = content.includes('\n') ? `\n${ageMatch[1]}` : content.length > 0 ? ' ' : ''; + const replacement = `${excludesMatch[1]}[${content}${separator}${spacing}${addition}${trailingWhitespace}]${excludesMatch[3]}`; + nextSectionContent = section.replace(excludesRe, replacement); + } else { + const insertionAt = (ageMatch.index ?? 0) + ageMatch[0].length; + const insertion = `\n${ageMatch[1]}minimumReleaseAgeExcludes = [${MANAGED_AGE_GATE_EXCLUDES.map((name) => JSON.stringify(name)).join(', ')}]`; + nextSectionContent = section.slice(0, insertionAt) + insertion + section.slice(insertionAt); + } + fs.writeFileSync( + bunfigPath, + original.slice(0, sectionStart) + nextSectionContent + original.slice(sectionEnd), + ); + return true; +} + +function reconcilePnpmPreviewTrustExcludes(projectRoot: string, version: string): boolean { + const workspacePath = path.join(projectRoot, 'pnpm-workspace.yaml'); + if (!fs.existsSync(workspacePath)) { + return false; + } + let changed = false; + editYamlFile(workspacePath, (doc) => { + const current = doc.get('trustPolicyExclude'); + let trustPolicyExclude: YAMLSeq>; + if (current instanceof YAMLSeq) { + trustPolicyExclude = current as YAMLSeq>; + } else { + trustPolicyExclude = new YAMLSeq>(); + } + + if (isPreviewVitePlusVersion(version) && doc.get('trustPolicy') === 'no-downgrade') { + const existing = new Set(trustPolicyExclude.items.map((item) => item.value)); + for (const packageName of PREVIEW_TRUST_PACKAGES) { + const selector = `${packageName}@${version}`; + if (!existing.has(selector)) { + trustPolicyExclude.add(scalarString(selector)); + changed = true; + } + } + if (changed || !(current instanceof YAMLSeq)) { + doc.set('trustPolicyExclude', trustPolicyExclude); + } + return; + } + + const kept = trustPolicyExclude.items.filter( + (item) => !PREVIEW_TRUST_SELECTOR_RE.test(item.value), + ); + if (kept.length === trustPolicyExclude.items.length) { + return; + } + changed = true; + if (kept.length === 0) { + doc.delete('trustPolicyExclude'); + } else { + trustPolicyExclude.items = kept; + doc.set('trustPolicyExclude', trustPolicyExclude); + } + }); + return changed; +} + +function reconcileManagedInstallPolicy( + projectRoot: string, + version: string, + packageManager: PackageManager | undefined, + packageManagerVersion: string | undefined, +): boolean { + switch (packageManager) { + case PackageManager.npm: + return ensureNpmMinimumReleaseAgeExcludes(projectRoot, packageManagerVersion); + case PackageManager.bun: + return ensureBunMinimumReleaseAgeExcludes(projectRoot); + case PackageManager.pnpm: + return reconcilePnpmPreviewTrustExcludes(projectRoot, version); + default: + return false; + } +} + // Comment attached to the bridge `npmRegistryServer` value so a later // real-release run can restore the user's original registry instead of // deleting it. Comments survive the YAML round-trip. @@ -192,7 +389,14 @@ export function reconcilePreviewBridgeRegistry( projectRoot: string, version: string = process.env.VP_VERSION || VITE_PLUS_VERSION, packageManager?: PackageManager, + packageManagerVersion?: string, ): boolean { + const installPolicyChanged = reconcileManagedInstallPolicy( + projectRoot, + version, + packageManager, + packageManagerVersion, + ); if (isPreviewVitePlusVersion(version)) { // Write the file the ACTIVE package manager reads: Yarn Berry uses // `.yarnrc.yml`, everything else uses `.npmrc`. Fall back to file-based @@ -214,5 +418,27 @@ export function reconcilePreviewBridgeRegistry( // files in case the project switched package managers since). const clearedNpmrc = clearNpmrcRegistry(projectRoot); const clearedYarnrc = clearYarnBerryRegistry(projectRoot); - return clearedNpmrc || clearedYarnrc; + return installPolicyChanged || clearedNpmrc || clearedYarnrc; +} + +/** + * npm only gained package-level `min-release-age` exclusions in 11.17. For an + * older npm migration, relax that one policy in the child install + * environment instead of persisting an unsupported `.npmrc` key. The project + * setting remains unchanged for every install outside this explicit migration. + */ +export function getManagedInstallEnv( + projectRoot: string, + packageManager: PackageManager | undefined, + packageManagerVersion: string | undefined, +): NodeJS.ProcessEnv { + const envs = { ...process.env }; + if ( + packageManager === PackageManager.npm && + hasNpmMinimumReleaseAge(projectRoot) && + !npmSupportsMinimumReleaseAgeExclude(packageManagerVersion) + ) { + envs.NPM_CONFIG_MIN_RELEASE_AGE = '0'; + } + return envs; } diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 34b42ff1cf..dffeed04fb 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -5,7 +5,7 @@ import { downloadPackageManager as downloadPackageManagerBinding } from '../../b import { PackageManager } from '../types/index.ts'; import { isPnpmIgnoredBuildsError, parseInstallGatedBuilds } from './approve-builds.ts'; import { runCommandSilently } from './command.ts'; -import { reconcilePreviewBridgeRegistry } from './preview-registry.ts'; +import { getManagedInstallEnv, reconcilePreviewBridgeRegistry } from './preview-registry.ts'; import { getSilentSpinner, getSpinner } from './spinner.ts'; import { accent } from './terminal.ts'; @@ -109,14 +109,20 @@ export async function runViteInstall( detectIgnoredBuilds?: boolean; }, ) { - // Reconcile the project's registry with this build before installing: a - // preview/test build points it at the registry bridge so the + // Reconcile the project's registry and managed-package install policies + // before installing. A preview/test build points it at the registry bridge so the // `0.0.0-commit.` versions migrate/create just pinned resolve (npmjs has // no such version); a real release removes any bridge registry a prior preview - // run left behind so installs don't hit the test bridge. No-op for a real build - // with no leftover. Done before the VP_SKIP_INSTALL return so it persists for - // the project's own CI even when this run skips the install. - reconcilePreviewBridgeRegistry(cwd, undefined, options?.packageManager); + // run left behind so installs don't hit the test bridge. Package age/trust + // policies receive only the exemptions needed by Vite+-managed artifacts. + // Done before the VP_SKIP_INSTALL return so supported settings persist for the + // project's own CI even when this run skips the install. + reconcilePreviewBridgeRegistry( + cwd, + undefined, + options?.packageManager, + options?.packageManagerVersion, + ); // install dependencies on non-CI environment if (process.env.VP_SKIP_INSTALL) { @@ -145,7 +151,7 @@ export async function runViteInstall( command: process.env.VP_CLI_BIN ?? 'vp', args: ['install', ...installArgs], cwd, - envs: process.env, + envs: getManagedInstallEnv(cwd, options?.packageManager, options?.packageManagerVersion), }); const combinedOutput = `${stdout.toString()}\n${stderr.toString()}`; const pendingBuilds = detectIgnoredBuilds From 58f1da601f79ba53dab4428d8a04ce58a678048b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 16 Jul 2026 09:58:08 +0800 Subject: [PATCH 2/2] test(migrate): update Bun age exclusion snapshot --- .../snapshots/migration_bunfig_inline_array.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_bunfig_inline_array/snapshots/migration_bunfig_inline_array.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_bunfig_inline_array/snapshots/migration_bunfig_inline_array.md index 2bf7d20796..83a77d21d4 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_bunfig_inline_array/snapshots/migration_bunfig_inline_array.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/migration_bunfig_inline_array/snapshots/migration_bunfig_inline_array.md @@ -19,5 +19,5 @@ check Bun configuration is unchanged ``` [install] minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@zerobyte/*"] +minimumReleaseAgeExcludes = ["@zerobyte/*", "vite-plus", "@voidzero-dev/vite-plus-core", "@voidzero-dev/vite-plus-darwin-arm64", "@voidzero-dev/vite-plus-darwin-x64", "@voidzero-dev/vite-plus-linux-arm64-gnu", "@voidzero-dev/vite-plus-linux-arm64-musl", "@voidzero-dev/vite-plus-linux-x64-gnu", "@voidzero-dev/vite-plus-linux-x64-musl", "@voidzero-dev/vite-plus-win32-arm64-msvc", "@voidzero-dev/vite-plus-win32-x64-msvc", "vitest", "@vitest/browser", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/expect", "@vitest/mocker", "@vitest/pretty-format", "@vitest/runner", "@vitest/snapshot", "@vitest/spy", "@vitest/ui", "@vitest/utils", "@vitest/web-worker", "@vitest/ws-client"] ```