diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index bcefe8a9f5..9d8dc1fe7f 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -32,6 +32,7 @@ const { detectPendingCoreMigration, detectVitePlusBootstrapPending, ensureVitePlusBootstrap, + ensurePnpmPeerOverrideCompatibility, finalizeCoreMigrationForExistingVitePlus, parseNvmrcVersion, detectNodeVersionManagerFile, @@ -3734,6 +3735,71 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.pnpm.peerDependencyRules.allowAny).toEqual(['react', 'vite']); }); + it('scopes managed alias overrides away from workspace peers on pnpm 10.0–10.1', () => { + const pluginDir = path.join(tmpDir, 'packages/plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + private: true, + devDependencies: { 'vite-plus': 'catalog:vite-stack' }, + pnpm: { overrides: { react: '18.3.1' } }, + }), + ); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'vite-plugin-test', + devDependencies: { 'vite-plus': 'catalog:vite-stack' }, + peerDependencies: { vite: '^7.3.0 || ^8.0.0' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'catalogs:', + ' vite-stack:', + ' vite: npm:@voidzero-dev/vite-plus-core@0.1.21', + ' vite-plus: 0.1.21', + '', + ].join('\n'), + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm, '10.0.0'); + workspaceInfo.isMonorepo = true; + workspaceInfo.workspacePatterns = ['packages/*']; + workspaceInfo.packages = [{ name: 'vite-plugin-test', path: 'packages/plugin' }]; + + ensureVitePlusBootstrap(workspaceInfo); + + const rootPkg = readJson(path.join(tmpDir, 'package.json')) as { + pnpm: { overrides: Record }; + }; + const pluginPkg = readJson(path.join(pluginDir, 'package.json')) as { + peerDependencies: Record; + }; + expect(rootPkg.pnpm.overrides.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(rootPkg.pnpm.overrides['vite-plugin-test>vite']).toBe('^7.3.0 || ^8.0.0'); + expect(pluginPkg.peerDependencies.vite).toBe('^7.3.0 || ^8.0.0'); + expect( + detectVitePlusBootstrapPending(tmpDir, PackageManager.pnpm, workspaceInfo.packages, '10.0.0'), + ).toBe(false); + + const fixedVersionOverrides: Record = {}; + expect( + ensurePnpmPeerOverrideCompatibility( + fixedVersionOverrides, + tmpDir, + workspaceInfo.packages, + '10.2.0', + true, + ), + ).toBe(false); + expect(fixedVersionOverrides).toEqual({}); + }); + it('preserves unknown package.json pnpm keys while moving supported settings', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 854a1e7c5a..cbc5e1c946 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -87,6 +87,8 @@ export function pnpmSupportsWorkspaceSettings(version: string): boolean { const PNPM_CATALOG_MIN_VERSION = '9.5.0'; +const PNPM_INVALID_PEER_OVERRIDE_FIXED_VERSION = '10.2.0'; + // pnpm catalogs (the `catalog:` protocol and the pnpm-workspace.yaml // `catalog`/`catalogs` fields) shipped in pnpm 9.5.0 as a minor change ("Added // support for catalogs", https://github.com/pnpm/pnpm/releases/tag/v9.5.0), and @@ -104,6 +106,123 @@ export function pnpmSupportsCatalog(version: string): boolean { return version === 'latest' || version === 'next'; } +// pnpm 10.0 and 10.1 apply root overrides to workspace peerDependencies before +// validating those peer specs. A managed npm:/file: alias therefore turns a +// valid public peer range into an invalid peer spec and aborts installation. +// pnpm fixed this in 10.2.0 by moving invalid overridden peers to production +// dependencies (pnpm/pnpm#9000). +function pnpmNeedsPeerOverrideCompatibility(version: string): boolean { + const coerced = semver.coerce(version); + return Boolean( + coerced && + semver.gte(coerced, '10.0.0') && + semver.lt(coerced, PNPM_INVALID_PEER_OVERRIDE_FIXED_VERSION), + ); +} + +function isValidPnpmPeerSpec( + spec: string | undefined, + dependencyName?: string, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + if (!spec) { + return false; + } + if (semver.validRange(spec) || spec.startsWith('workspace:')) { + return true; + } + if (!dependencyName || !spec.startsWith('catalog:')) { + return false; + } + const resolved = catalogDependencyResolver?.(spec, dependencyName); + return Boolean(resolved && (semver.validRange(resolved) || resolved.startsWith('workspace:'))); +} + +/** + * Keep managed alias overrides installable on pnpm 10.0–10.1 workspaces. + * + * A package-specific selector is more specific than the global managed + * override, so pnpm leaves that workspace package's public peer range valid + * while continuing to apply the managed override everywhere else. Existing + * valid scoped overrides are user policy and remain untouched. + */ +export function ensurePnpmPeerOverrideCompatibility( + overrides: Record, + projectPath: string, + packages: WorkspacePackage[] | undefined, + pnpmVersion: string, + usesVitest: boolean, +): boolean { + if (!pnpmNeedsPeerOverrideCompatibility(pnpmVersion)) { + return false; + } + const catalogDependencyResolver = createCatalogDependencyResolver( + projectPath, + PackageManager.pnpm, + ); + const managed = managedOverridePackages(usesVitest); + const invalidManagedPeers = Object.entries(managed) + .filter(([name, spec]) => !isValidPnpmPeerSpec(spec, name, catalogDependencyResolver)) + .map(([name]) => name); + if (invalidManagedPeers.length === 0) { + return false; + } + + let changed = false; + const packagePaths = [ + projectPath, + ...(packages ?? []).map((pkg) => path.join(projectPath, pkg.path)), + ]; + for (const packagePath of packagePaths) { + const packageJsonPath = path.join(packagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const pkg = readJsonFile(packageJsonPath) as { + name?: string; + peerDependencies?: Record; + }; + if (!pkg.name || !pkg.peerDependencies) { + continue; + } + for (const dependencyName of invalidManagedPeers) { + const declaredPeerSpec = pkg.peerDependencies[dependencyName]; + const peerSpec = declaredPeerSpec?.startsWith('catalog:') + ? getCatalogDependencySpec(declaredPeerSpec, managed[dependencyName], true, { + dependencyField: 'peerDependencies', + dependencyName, + catalogDependencyResolver, + }) + : declaredPeerSpec; + if (!isValidPnpmPeerSpec(peerSpec, dependencyName, catalogDependencyResolver)) { + continue; + } + const selector = `${pkg.name}>${dependencyName}`; + if (!isValidPnpmPeerSpec(overrides[selector], dependencyName, catalogDependencyResolver)) { + overrides[selector] = peerSpec; + changed = true; + } + } + } + return changed; +} + +export function pnpmPeerOverrideCompatibilityPending( + overrides: Record | undefined, + projectPath: string, + packages: WorkspacePackage[] | undefined, + pnpmVersion: string, + usesVitest: boolean, +): boolean { + return ensurePnpmPeerOverrideCompatibility( + { ...overrides }, + projectPath, + packages, + pnpmVersion, + usesVitest, + ); +} + const YARN_CATALOG_MIN_VERSION = '4.10.0'; // Yarn's `catalog:` protocol (and the `.yarnrc.yml` `catalog`/`catalogs` fields) @@ -1249,6 +1368,15 @@ export function rewriteRootWorkspacePackageJson( } } } + if (pkg.pnpm?.overrides && !usePnpmWorkspaceSettings) { + ensurePnpmPeerOverrideCompatibility( + pkg.pnpm.overrides, + projectPath, + packages, + pnpmVersion ?? '', + workspaceUsesVitest, + ); + } if (pnpmMajorVersion !== undefined && pkg.pnpm) { applyBuildAllowanceToPackageJsonPnpm(pkg.pnpm, pnpmMajorVersion, shouldAllowBrowserBuilds); } diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index ffdf0d776e..9434ee36f8 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -20,6 +20,7 @@ import { collectVitestEcosystemInstallDependencyNames, createCatalogDependencyResolver, ensureDirectViteForPnpm, + ensurePnpmPeerOverrideCompatibility, ensurePnpmWorkspacePackages, findYarnWorkspaceHoisting, getAlignedVitestEcosystemDependencySpec, @@ -31,6 +32,7 @@ import { migratePnpmSettingsToWorkspaceYaml, normalizeVitestPeerCatalogSpec, pnpmPackageJsonSettingsPending, + pnpmPeerOverrideCompatibilityPending, pnpmSupportsWorkspaceSettings, supportsCatalog, pnpmWorkspaceMinimumReleaseAgeExemptionsPending, @@ -744,12 +746,26 @@ export function detectVitePlusBootstrapPending( return ( catalogVitePlusDependencyPending(pkg, catalogDependencyResolver) || !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest, catalogDependencyResolver) || + pnpmPeerOverrideCompatibilityPending( + pkg.pnpm?.overrides, + projectPath, + packages, + resolvedPackageManagerVersion, + usesVitest, + ) || !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) ); } return ( vitePlusDependencyNeedsConcreteVersion(pkg) || !overridesSatisfyVitePlus(pkg.pnpm?.overrides, usesVitest) || + pnpmPeerOverrideCompatibilityPending( + pkg.pnpm?.overrides, + projectPath, + packages, + resolvedPackageManagerVersion, + usesVitest, + ) || !pnpmPeerDependencyRulesSatisfyVitePlus(pkg.pnpm?.peerDependencyRules, usesVitest) ); } @@ -1057,6 +1073,14 @@ export function ensureVitePlusBootstrap( pkg.pnpm.overrides = ensured.overrides; packageJsonChanged = true; } + packageJsonChanged = + ensurePnpmPeerOverrideCompatibility( + (pkg.pnpm.overrides ??= ensured.overrides), + projectPath, + workspaceInfo.packages, + workspaceInfo.downloadPackageManager.version, + usesVitest, + ) || packageJsonChanged; packageJsonChanged = ensurePnpmPeerDependencyRules(pkg, usesVitest) || packageJsonChanged; if (pnpmMajorVersion !== undefined && pkg.pnpm) { const beforePnpm = JSON.stringify(pkg.pnpm);