Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/cli/src/migration/__tests__/migrator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const {
detectPendingCoreMigration,
detectVitePlusBootstrapPending,
ensureVitePlusBootstrap,
ensurePnpmPeerOverrideCompatibility,
finalizeCoreMigrationForExistingVitePlus,
parseNvmrcVersion,
detectNodeVersionManagerFile,
Expand Down Expand Up @@ -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<string, string> };
};
const pluginPkg = readJson(path.join(pluginDir, 'package.json')) as {
peerDependencies: Record<string, string>;
};
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<string, string> = {};
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'),
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/migration/migrator/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, string>,
projectPath: string,
packages: WorkspacePackage[] | undefined,
pnpmVersion: string,
usesVitest: boolean,
): boolean {
if (!pnpmNeedsPeerOverrideCompatibility(pnpmVersion)) {
return false;
Comment on lines +156 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove obsolete pnpm peer-compat selectors

After this helper has written pkg>vite selectors for pnpm 10.0/10.1, rerunning the bootstrap on pnpm 10.2.0–10.6.1 exits here and never removes them; the workspace-settings path only cleans selector overrides at 10.6.2+. Those selectors are more specific than the global vite override (pnpm documents qar@1>zoo as overriding that package's zoo dependency), so the stale public range continues to bypass the Vite+ core alias for that workspace even though the pnpm peer-validation bug is fixed.

Useful? React with 👍 / 👎.

}
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<string, string>;
};
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<string, string> | 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)
Expand Down Expand Up @@ -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);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/migration/migrator/vite-plus-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
collectVitestEcosystemInstallDependencyNames,
createCatalogDependencyResolver,
ensureDirectViteForPnpm,
ensurePnpmPeerOverrideCompatibility,
ensurePnpmWorkspacePackages,
findYarnWorkspaceHoisting,
getAlignedVitestEcosystemDependencySpec,
Expand All @@ -31,6 +32,7 @@ import {
migratePnpmSettingsToWorkspaceYaml,
normalizeVitestPeerCatalogSpec,
pnpmPackageJsonSettingsPending,
pnpmPeerOverrideCompatibilityPending,
pnpmSupportsWorkspaceSettings,
supportsCatalog,
pnpmWorkspaceMinimumReleaseAgeExemptionsPending,
Expand Down Expand Up @@ -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)
);
}
Expand Down Expand Up @@ -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);
Expand Down
Loading