-
Notifications
You must be signed in to change notification settings - Fork 234
fix(migrate): support guarded package installs #2185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.<sha>` | ||
| * 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<string>(); | ||
| 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(',') ? '' : ','; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Bun projects whose existing Useful? React with 👍 / 👎. |
||
| 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<Scalar<string>>; | ||
| if (current instanceof YAMLSeq) { | ||
| trustPolicyExclude = current as YAMLSeq<Scalar<string>>; | ||
| } else { | ||
| trustPolicyExclude = new YAMLSeq<Scalar<string>>(); | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With npm or Bun age gates, this combined list is the complete set of package-name exclusions written before
vp install. It covers Vite+/native packages and Vitest, but omits direct toolchain dependencies that the publishedvite-pluspackage installs (oxfmt,oxlint,oxlint-tsgolint,@oxlint/plugins,@oxc-project/types; seepackages/cli/package.json:361-377), while the existing pnpm age-gate code already treats the ox family as Vite+-managed (packages/cli/src/migration/migrator/catalog.ts:62-72). If one of those exact bundled versions is still younger than the user's npm/Bun release-age gate, migrate/create installs will still be rejected despite this reconciliation.Useful? React with 👍 / 👎.