From 287327e6c32735871892994b5dc2fc3254d176c2 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 11:45:43 +0800 Subject: [PATCH 01/12] fix(migration): rewrite named catalogs --- .../snap.txt | 5 +- .../snap.txt | 2 +- .../bun-catalog-file-protocol.spec.ts | 40 +++ .../src/migration/__tests__/migrator.spec.ts | 308 ++++++++++++++++++ packages/cli/src/migration/migrator.ts | 188 ++++++++--- packages/cli/src/utils/workspace.ts | 8 +- packages/test/build.ts | 2 + 7 files changed, 504 insertions(+), 49 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index f50035fec4..b8d941ab89 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -42,6 +42,9 @@ export default defineConfig({ { "name": "my-vite-plugin", "peerDependencies": { - "vite": "^6.0.0" + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 819644c0d1..b42682e4fb 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -30,7 +30,7 @@ export default defineConfig({ { "name": "migration-skip-vite-peer-dependency", "peerDependencies": { - "vite": "^6.0.0" + "vite": "catalog:" }, "devDependencies": { "vite-plus": "catalog:" diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index 1f66f21036..f8ad5c9425 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -86,4 +86,44 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { 'file:/tmp/tgz/voidzero-dev-vite-plus-test-0.0.0.tgz', ); }); + + it('does not write file: paths into named catalogs', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-monorepo', + workspaces: { + packages: ['packages/*'], + catalogs: { + build: { + vite: '^7.0.0', + vitest: '^4.0.0', + tsdown: '^0.1.0', + }, + }, + }, + devDependencies: { vite: 'catalog:build' }, + overrides: { vite: 'catalog:build' }, + packageManager: 'bun@1.3.11', + }), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun), true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + workspaces: { + catalog: Record; + catalogs: Record>; + }; + overrides: Record; + devDependencies: Record; + }; + expect(pkg.workspaces.catalog.vite).toBeUndefined(); + expect(pkg.workspaces.catalog.vitest).toBeUndefined(); + expect(pkg.workspaces.catalogs.build.vite).toBe('^7.0.0'); + expect(pkg.workspaces.catalogs.build.vitest).toBe('^4.0.0'); + expect(pkg.workspaces.catalogs.build.tsdown).toBeUndefined(); + expect(pkg.overrides.vite).toBe('file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz'); + expect(pkg.devDependencies.vite).toBe('file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz'); + }); }); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 729064a217..b0471207b1 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parse as parseYaml } from 'yaml'; import { PackageManager } from '../../types/index.js'; import { createMigrationReport } from '../report.js'; @@ -136,6 +137,65 @@ describe('rewritePackageJson', () => { expect(pkg).toMatchSnapshot(); }); + it('preserves named catalog dependency specs in monorepo projects', async () => { + const pkg = { + devDependencies: { + vite: 'catalog:vite7', + vitest: 'catalog:', + }, + dependencies: { + vitest: 'catalog:test', + }, + }; + + rewritePackageJson(pkg, PackageManager.pnpm, true); + + expect(pkg.devDependencies.vite).toBe('catalog:vite7'); + expect(pkg.devDependencies.vitest).toBe('catalog:'); + expect(pkg.dependencies.vitest).toBe('catalog:test'); + expect((pkg.devDependencies as Record)['vite-plus']).toBe('catalog:'); + }); + + it('uses default catalog specs for non-catalog dependency specs in monorepo projects', async () => { + const pkg = { + devDependencies: { + vite: '^7.0.0', + }, + dependencies: { + vitest: '^4.0.0', + }, + }; + + rewritePackageJson(pkg, PackageManager.yarn, true); + + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.dependencies.vitest).toBe('catalog:'); + expect((pkg.devDependencies as Record)['vite-plus']).toBe('catalog:'); + }); + + it('rewrites peer and optional dependency catalog specs in monorepo projects', async () => { + const pkg = { + peerDependencies: { + vite: 'catalog:vite7', + tsdown: 'catalog:build', + }, + optionalDependencies: { + vitest: 'catalog:test', + oxlint: 'catalog:build', + }, + }; + + rewritePackageJson(pkg, PackageManager.pnpm, true); + + expect(pkg.peerDependencies.vite).toBe('catalog:vite7'); + expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); + expect(pkg.optionalDependencies.vitest).toBe('catalog:test'); + expect(pkg.optionalDependencies).not.toHaveProperty('oxlint'); + expect( + (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], + ).toBe('catalog:'); + }); + it('should preserve playwright when removing @vitest/browser-playwright', async () => { const pkg = { devDependencies: { @@ -454,6 +514,10 @@ function readYaml(filePath: string): string { return fs.readFileSync(filePath, 'utf8'); } +function readYamlObject(filePath: string): Record { + return parseYaml(readYaml(filePath)) as Record; +} + describe('rewriteStandaloneProject pnpm workspace yaml', () => { let tmpDir: string; @@ -560,6 +624,147 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(yaml).toContain("vite: 'catalog:'"); expect(yaml).toContain("vitest: 'catalog:'"); }); + + it('rewrites named catalogs in pnpm-workspace.yaml without adding new entries', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + devDependencies: { vite: 'catalog:vite7' }, + peerDependencies: { tsdown: 'catalog:test' }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'overrides:', + ' vite: catalog:vite7', + 'catalogs:', + ' vite7:', + ' react: ^18.0.0', + ' vite: ^7.0.0', + ' vite-plus: ^0.0.0', + ' test:', + ' vitest: ^4.0.0', + ' tsdown: ^0.1.0', + '', + ].join('\n'), + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + overrides: Record; + catalogs: Record>; + }; + expect(yaml.overrides.vite).toBe('catalog:vite7'); + expect(yaml.overrides.vitest).toBe('catalog:'); + expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(yaml.catalogs.vite7.react).toBe('^18.0.0'); + expect(yaml.catalogs.vite7['vite-plus']).toBe('latest'); + expect(yaml.catalogs.test.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); + expect(yaml.catalogs.test.tsdown).toBeUndefined(); + expect(yaml.catalogs.test['vite-plus']).toBeUndefined(); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + peerDependencies: Record; + }; + expect(pkg.devDependencies.vite).toBe('catalog:vite7'); + expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); + }); + + it('preserves named pnpm overrides when moving root overrides to pnpm-workspace.yaml', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'pnpm-monorepo', + workspaces: ['packages/*'], + devDependencies: { vite: 'catalog:vite7' }, + pnpm: { + overrides: { + vite: 'catalog:vite7', + react: '^18.0.0', + }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + ['packages:', ' - packages/*', 'catalogs:', ' vite7:', ' vite: ^7.0.0', ''].join('\n'), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true); + + const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + overrides: Record; + catalogs: Record>; + }; + expect(yaml.overrides.vite).toBe('catalog:vite7'); + expect(yaml.overrides.vitest).toBe('catalog:'); + expect(yaml.overrides.react).toBe('^18.0.0'); + expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + pnpm?: unknown; + }; + expect(pkg.pnpm).toBeUndefined(); + }); +}); + +describe('rewriteMonorepo yarn catalog', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-yarn-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rewrites named catalogs in .yarnrc.yml and keeps nodeLinker', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'yarn-monorepo', + workspaces: ['packages/*'], + devDependencies: { vite: 'catalog:vite7' }, + packageManager: 'yarn@4.10.0', + }), + ); + fs.writeFileSync( + path.join(tmpDir, '.yarnrc.yml'), + [ + 'catalogs:', + ' vite7:', + ' react: ^18.0.0', + ' vite: ^7.0.0', + ' test:', + ' vitest: ^4.0.0', + ' oxlint: ^1.0.0', + '', + ].join('\n'), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.yarn), true); + + const yarnrc = readYamlObject(path.join(tmpDir, '.yarnrc.yml')) as { + nodeLinker: string; + catalogs: Record>; + }; + expect(yarnrc.nodeLinker).toBe('node-modules'); + expect(yarnrc.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(yarnrc.catalogs.vite7.react).toBe('^18.0.0'); + expect(yarnrc.catalogs.test.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); + expect(yarnrc.catalogs.test.oxlint).toBeUndefined(); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + }; + expect(pkg.devDependencies.vite).toBe('catalog:vite7'); + }); }); describe('rewriteMonorepo bun catalog', () => { @@ -645,6 +850,109 @@ describe('rewriteMonorepo bun catalog', () => { const workspaces = pkg.workspaces as { packages: string[] }; expect(workspaces.packages).toEqual(['packages/*']); }); + + it('rewrites top-level named catalogs and preserves named overrides', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-monorepo', + workspaces: ['packages/*'], + catalogs: { + build: { vite: '^7.0.0', react: '^19.0.0', tsdown: '^0.1.0' }, + test: { vitest: '^4.0.0' }, + }, + overrides: { vite: 'catalog:build' }, + devDependencies: { vite: 'catalog:build' }, + packageManager: 'bun@1.3.11', + }), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun), true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + catalog: Record; + catalogs: Record>; + overrides: Record; + devDependencies: Record; + }; + expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.catalogs.build.react).toBe('^19.0.0'); + expect(pkg.catalogs.build.tsdown).toBeUndefined(); + expect(pkg.catalogs.test.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); + expect(pkg.overrides.vite).toBe('catalog:build'); + expect(pkg.overrides.vitest).toBe('catalog:'); + expect(pkg.devDependencies.vite).toBe('catalog:build'); + }); + + it('rewrites workspaces named catalogs and writes default catalog beside them', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-monorepo', + workspaces: { + packages: ['packages/*'], + catalogs: { + build: { vite: '^7.0.0', oxlint: '^1.0.0' }, + test: { vitest: '^4.0.0', vite: '^7.0.0' }, + }, + }, + devDependencies: { vite: '^7.0.0' }, + packageManager: 'bun@1.3.11', + }), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun), true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + catalog?: Record; + workspaces: { + catalog: Record; + catalogs: Record>; + }; + overrides: Record; + }; + expect(pkg.catalog).toBeUndefined(); + expect(pkg.workspaces.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.workspaces.catalog['vite-plus']).toBe('latest'); + expect(pkg.workspaces.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.workspaces.catalogs.build.oxlint).toBeUndefined(); + expect(pkg.workspaces.catalogs.test.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); + expect(pkg.workspaces.catalogs.test.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.overrides.vite).toBe('catalog:'); + }); + + it('keeps an existing top-level default catalog when workspaces named catalogs exist', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-monorepo', + workspaces: { + packages: ['packages/*'], + catalogs: { + build: { vite: '^7.0.0' }, + }, + }, + catalog: { react: '^19.0.0' }, + devDependencies: { vite: '^7.0.0' }, + packageManager: 'bun@1.3.11', + }), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun), true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + catalog: Record; + workspaces: { + catalog?: Record; + catalogs: Record>; + }; + }; + expect(pkg.catalog.react).toBe('^19.0.0'); + expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.workspaces.catalog).toBeUndefined(); + expect(pkg.workspaces.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + }); }); describe('framework shim', () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index be37e0442d..13c6cb42c5 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1035,20 +1035,23 @@ function rewritePnpmWorkspaceYaml(projectPath: string): void { rewriteCatalog(doc); // overrides + const overrides = doc.getIn(['overrides']); for (const key of Object.keys(VITE_PLUS_OVERRIDE_PACKAGES)) { - let version = VITE_PLUS_OVERRIDE_PACKAGES[key]; - if (!version.startsWith('file:')) { - version = 'catalog:'; - } + const currentVersion = getYamlMapScalarStringValue(overrides, key); + const version = getCatalogDependencySpec( + currentVersion, + VITE_PLUS_OVERRIDE_PACKAGES[key], + true, + ); doc.setIn(['overrides', scalarString(key)], scalarString(version)); } // remove dependency selector from vite, e.g. "vite-plugin-svgr>vite": "npm:vite@7.0.12" - const overrides = doc.getIn(['overrides']) as YAMLMap, Scalar>; - for (const item of overrides.items) { + const updatedOverrides = doc.getIn(['overrides']) as YAMLMap, Scalar>; + for (const item of updatedOverrides.items) { if (item.key.value.includes('>')) { const splits = item.key.value.split('>'); if (splits[splits.length - 1].trim() === 'vite') { - overrides.delete(item.key); + updatedOverrides.delete(item.key); } } } @@ -1125,9 +1128,15 @@ function cleanupPnpmOverridesForWorkspaceYaml( overrideKeys: string[], ): Record | undefined { // Remove Vite-managed keys from pnpm.overrides + const namedCatalogOverrides: Record = {}; + const overrides = pkg.pnpm?.overrides; for (const key of [...overrideKeys, ...REMOVE_PACKAGES]) { - if (pkg.pnpm?.overrides?.[key]) { - delete pkg.pnpm.overrides[key]; + const value = overrides?.[key]; + if (value) { + if (overrideKeys.includes(key) && value.startsWith('catalog:') && value !== 'catalog:') { + namedCatalogOverrides[key] = value; + } + delete overrides[key]; } } // Remove dependency selectors targeting vite @@ -1142,8 +1151,11 @@ function cleanupPnpmOverridesForWorkspaceYaml( // Collect remaining overrides to move to pnpm-workspace.yaml then delete all // (pnpm ignores workspace-level overrides when pnpm.overrides exists in package.json) let remaining: Record | undefined; + if (Object.keys(namedCatalogOverrides).length > 0) { + remaining = { ...namedCatalogOverrides }; + } if (pkg.pnpm?.overrides && Object.keys(pkg.pnpm.overrides).length > 0) { - remaining = { ...pkg.pnpm.overrides }; + remaining = { ...remaining, ...pkg.pnpm.overrides }; } delete pkg.pnpm?.overrides; // Only remove Vite-managed peerDependencyRules entries, preserve custom ones @@ -1229,6 +1241,34 @@ function rewriteYarnrcYml(projectPath: string): void { * Rewrite catalog in pnpm-workspace.yaml or .yarnrc.yml * @param doc - The document to rewrite */ +function getCatalogDependencySpec( + currentValue: string | undefined, + version: string, + supportCatalog: boolean, +): string { + if (!supportCatalog || version.startsWith('file:')) { + return version; + } + return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; +} + +function getYamlMapScalarStringValue(map: unknown, key: string): string | undefined { + if (!(map instanceof YAMLMap)) { + return undefined; + } + for (const item of map.items) { + if ( + item.key instanceof Scalar && + item.key.value === key && + item.value instanceof Scalar && + typeof item.value.value === 'string' + ) { + return item.value.value; + } + } + return undefined; +} + function rewriteCatalog(doc: YamlDocument): void { for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { // ERR_PNPM_CATALOG_IN_OVERRIDES  Could not resolve a catalog in the overrides: The entry for 'vite' in catalog 'default' declares a dependency using the 'file' protocol @@ -1248,7 +1288,53 @@ function rewriteCatalog(doc: YamlDocument): void { } } - // TODO: rewrite `catalogs` when OVERRIDE_PACKAGES exists in catalog + const catalogs = doc.getIn(['catalogs']); + if (!(catalogs instanceof YAMLMap)) { + return; + } + for (const item of catalogs.items) { + const catalogName = item.key instanceof Scalar ? item.key.value : undefined; + if (typeof catalogName !== 'string' || !(item.value instanceof YAMLMap)) { + continue; + } + for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + const catalogPath = ['catalogs', catalogName, key]; + if (!value.startsWith('file:') && doc.hasIn(catalogPath)) { + doc.setIn(catalogPath, scalarString(value)); + } + } + const vitePlusPath = ['catalogs', catalogName, VITE_PLUS_NAME]; + if (!VITE_PLUS_VERSION.startsWith('file:') && doc.hasIn(vitePlusPath)) { + doc.setIn(vitePlusPath, scalarString(VITE_PLUS_VERSION)); + } + for (const name of REMOVE_PACKAGES) { + const catalogPath = ['catalogs', catalogName, name]; + if (doc.hasIn(catalogPath)) { + doc.deleteIn(catalogPath); + } + } + } +} + +function rewriteCatalogObject(catalog: Record, addMissing: boolean): void { + for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { + if (value.startsWith('file:') || (!addMissing && !(key in catalog))) { + continue; + } + catalog[key] = value; + } + if (!VITE_PLUS_VERSION.startsWith('file:') && (addMissing || VITE_PLUS_NAME in catalog)) { + catalog[VITE_PLUS_NAME] = VITE_PLUS_VERSION; + } + for (const name of REMOVE_PACKAGES) { + delete catalog[name]; + } +} + +function rewriteCatalogsObject(catalogs: Record>): void { + for (const catalog of Object.values(catalogs)) { + rewriteCatalogObject(catalog, false); + } } /** @@ -1266,39 +1352,37 @@ function rewriteBunCatalog(projectPath: string): void { editJsonFile<{ workspaces?: NpmWorkspaces; catalog?: Record; + catalogs?: Record>; overrides?: Record; }>(packageJsonPath, (pkg) => { // Bun supports catalogs in both workspaces.catalog and top-level catalog; // prefer the location the user already chose to avoid moving their config. const workspacesObj = pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined; + const useWorkspacesCatalog = + workspacesObj?.catalog != null || (pkg.catalog == null && workspacesObj?.catalogs != null); const catalog: Record = { - ...(workspacesObj?.catalog ?? pkg.catalog), + ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), }; - for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - if (!value.startsWith('file:')) { - catalog[key] = value; - } - } - if (!VITE_PLUS_VERSION.startsWith('file:')) { - catalog[VITE_PLUS_NAME] = VITE_PLUS_VERSION; - } - - for (const name of REMOVE_PACKAGES) { - delete catalog[name]; - } + rewriteCatalogObject(catalog, true); - if (workspacesObj?.catalog != null) { + if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; } else { pkg.catalog = catalog; } + if (workspacesObj?.catalogs) { + rewriteCatalogsObject(workspacesObj.catalogs); + } + if (pkg.catalogs) { + rewriteCatalogsObject(pkg.catalogs); + } // bun overrides support catalog: references const overrides: Record = { ...pkg.overrides }; for (const [key, value] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - overrides[key] = value.startsWith('file:') ? value : 'catalog:'; + overrides[key] = getCatalogDependencySpec(overrides[key], value, true); } pkg.overrides = overrides; @@ -1435,6 +1519,8 @@ export function rewritePackageJson( 'lint-staged'?: Record; devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; }, packageManager: PackageManager, isMonorepo?: boolean, @@ -1458,38 +1544,43 @@ export function rewritePackageJson( const updated = rewriteScripts(JSON.stringify(config), readRulesYaml()); extractedStagedConfig = updated ? JSON.parse(updated) : config; } - const supportCatalog = isMonorepo && packageManager !== PackageManager.npm; + const supportCatalog = !!isMonorepo && packageManager !== PackageManager.npm; let needVitePlus = false; + const dependencyGroups = [ + pkg.devDependencies, + pkg.dependencies, + pkg.peerDependencies, + pkg.optionalDependencies, + ]; for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - const value = supportCatalog && !version.startsWith('file:') ? 'catalog:' : version; - if (pkg.devDependencies?.[key]) { - pkg.devDependencies[key] = value; - needVitePlus = true; - } - if (pkg.dependencies?.[key]) { - pkg.dependencies[key] = value; - needVitePlus = true; + for (const dependencies of dependencyGroups) { + if (dependencies?.[key]) { + dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog); + needVitePlus = true; + } } } // remove packages that are replaced with vite-plus for (const name of REMOVE_PACKAGES) { - const wasInDevDeps = !!pkg.devDependencies?.[name]; - const wasInDeps = !!pkg.dependencies?.[name]; - if (wasInDevDeps) { - delete pkg.devDependencies![name]; - needVitePlus = true; + let wasRemoved = false; + for (const dependencies of dependencyGroups) { + if (dependencies?.[name]) { + delete dependencies[name]; + wasRemoved = true; + } } - if (wasInDeps) { - delete pkg.dependencies![name]; + if (wasRemoved) { needVitePlus = true; } // e.g., removing @vitest/browser-playwright should keep `playwright` in devDeps const peerDep = BROWSER_PROVIDER_PEER_DEPS[name]; if ( - (wasInDevDeps || wasInDeps) && + wasRemoved && peerDep && !pkg.devDependencies?.[peerDep] && - !pkg.dependencies?.[peerDep] + !pkg.dependencies?.[peerDep] && + !pkg.peerDependencies?.[peerDep] && + !pkg.optionalDependencies?.[peerDep] ) { pkg.devDependencies ??= {}; pkg.devDependencies[peerDep] = '*'; @@ -1507,10 +1598,15 @@ export function rewritePackageJson( // on vitest (e.g., vitest-browser-svelte). Without this, pnpm resolves the real // vitest for peer deps instead of @voidzero-dev/vite-plus-test, causing // third-party type augmentations to target the wrong module. - const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + ...pkg.optionalDependencies, + }; if (!allDeps.vitest && Object.keys(allDeps).some((name) => name.includes('vitest'))) { const ver = VITE_PLUS_OVERRIDE_PACKAGES.vitest; - pkg.devDependencies.vitest = supportCatalog && !ver.startsWith('file:') ? 'catalog:' : ver; + pkg.devDependencies.vitest = getCatalogDependencySpec(undefined, ver, supportCatalog); } } return extractedStagedConfig; diff --git a/packages/cli/src/utils/workspace.ts b/packages/cli/src/utils/workspace.ts index 0b5510b36b..c1e996e3f4 100644 --- a/packages/cli/src/utils/workspace.ts +++ b/packages/cli/src/utils/workspace.ts @@ -18,7 +18,13 @@ import { getScopeFromPackageName } from './package.ts'; import { editYamlFile, readYamlFile } from './yaml.ts'; // npm/yarn use an array; Bun catalogs and Yarn classic nohoist use an object with `packages`. -export type NpmWorkspaces = string[] | { packages?: string[]; catalog?: Record }; +export type NpmWorkspaces = + | string[] + | { + packages?: string[]; + catalog?: Record; + catalogs?: Record>; + }; export function findPackageJsonFilesFromPatterns(patterns: string[], cwd: string): string[] { if (patterns.length === 0) { diff --git a/packages/test/build.ts b/packages/test/build.ts index 444963eec7..3c081b53c3 100644 --- a/packages/test/build.ts +++ b/packages/test/build.ts @@ -177,6 +177,8 @@ const CJS_REEXPORT_PACKAGES = new Set(['expect-type']); // Node built-in modules (including node: prefix variants) const NODE_BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); +await rm(distDir, { recursive: true, force: true }); + // Step 1: Copy vitest-dev dist files (rewriting vite -> core package) await bundleVitest(); From 15644310bf448f0f2a1e6a12b8b78119ba3ab04e Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 12:27:44 +0800 Subject: [PATCH 02/12] test(env): isolate pin cache invalidation tests --- .../vite_global_cli/src/commands/env/pin.rs | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/pin.rs b/crates/vite_global_cli/src/commands/env/pin.rs index 5f891dd05c..6e326b0ea0 100644 --- a/crates/vite_global_cli/src/commands/env/pin.rs +++ b/crates/vite_global_cli/src/commands/env/pin.rs @@ -220,12 +220,40 @@ pub async fn do_unpin(cwd: &AbsolutePathBuf) -> Result { #[cfg(test)] mod tests { + use std::ffi::OsString; + use serial_test::serial; use tempfile::TempDir; use vite_path::AbsolutePathBuf; use super::*; + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &std::path::Path) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + #[tokio::test] async fn test_show_pinned_no_file() { let temp_dir = TempDir::new().unwrap(); @@ -267,9 +295,11 @@ mod tests { } #[tokio::test] + #[serial] async fn test_do_unpin() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _vp_home = EnvVarGuard::set(vite_shared::env_vars::VP_HOME, temp_path.as_path()); // Create .node-version let node_version_path = temp_path.join(".node-version"); @@ -290,10 +320,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vite_shared::env_vars::VP_HOME, temp_path.as_path()); - } + let _vp_home = EnvVarGuard::set(vite_shared::env_vars::VP_HOME, temp_path.as_path()); // Create cache file manually let cache_dir = temp_path.join("cache"); @@ -316,11 +343,6 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after unpin" ); - - // Cleanup - unsafe { - std::env::remove_var(vite_shared::env_vars::VP_HOME); - } } // Run serially: mutates VP_HOME env var which affects invalidate_cache() @@ -330,10 +352,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vite_shared::env_vars::VP_HOME, temp_path.as_path()); - } + let _vp_home = EnvVarGuard::set(vite_shared::env_vars::VP_HOME, temp_path.as_path()); // Create cache file manually let cache_dir = temp_path.join("cache"); @@ -360,11 +379,6 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after pin" ); - - // Cleanup - unsafe { - std::env::remove_var(vite_shared::env_vars::VP_HOME); - } } #[tokio::test] From a062e2c97b2342069ee46f06cc56d77254507d28 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 12:55:40 +0800 Subject: [PATCH 03/12] fix(migration): keep file specs out of peer deps --- .../bun-catalog-file-protocol.spec.ts | 25 ++++++++++++++++++- packages/cli/src/migration/migrator.ts | 8 +++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index f8ad5c9425..f6706339af 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -21,7 +21,7 @@ vi.mock('../../utils/constants.js', async (importOriginal) => { }; }); -const { rewriteMonorepo } = await import('../migrator.js'); +const { rewriteMonorepo, rewritePackageJson } = await import('../migrator.js'); function makeWorkspaceInfo(rootDir: string, packageManager: PackageManager): WorkspaceInfo { return { @@ -126,4 +126,27 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { expect(pkg.overrides.vite).toBe('file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz'); expect(pkg.devDependencies.vite).toBe('file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz'); }); + + it('does not write file: paths into peer dependencies', () => { + const pkg = { + peerDependencies: { + vite: '^7.0.0', + vitest: 'catalog:test', + }, + optionalDependencies: { + vite: '^7.0.0', + }, + }; + + rewritePackageJson(pkg, PackageManager.pnpm, true); + + expect(pkg.peerDependencies.vite).toBe('^7.0.0'); + expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + expect(pkg.optionalDependencies.vite).toBe( + 'file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz', + ); + expect( + (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], + ).toBe('file:/tmp/tgz/vite-plus-0.0.0.tgz'); + }); }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 13c6cb42c5..23f9fad4c3 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1245,8 +1245,12 @@ function getCatalogDependencySpec( currentValue: string | undefined, version: string, supportCatalog: boolean, + options?: { allowFileProtocol?: boolean }, ): string { if (!supportCatalog || version.startsWith('file:')) { + if (version.startsWith('file:') && options?.allowFileProtocol === false) { + return currentValue ?? version; + } return version; } return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; @@ -1555,7 +1559,9 @@ export function rewritePackageJson( for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { for (const dependencies of dependencyGroups) { if (dependencies?.[key]) { - dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog); + dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { + allowFileProtocol: dependencies !== pkg.peerDependencies, + }); needVitePlus = true; } } From 22fc1727456deaad2095bbbb005f1d72ad80d1ad Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 13:03:42 +0800 Subject: [PATCH 04/12] fix(migration): preserve peer dependency ranges --- .../src/migration/__tests__/migrator.spec.ts | 21 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 13 +++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index b0471207b1..9b59c79dde 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -196,6 +196,27 @@ describe('rewritePackageJson', () => { ).toBe('catalog:'); }); + it('preserves peer dependency ranges outside catalog mode', async () => { + const pkg = { + peerDependencies: { + vite: '^7.0.0', + vitest: '^4.0.0', + }, + optionalDependencies: { + vite: '^7.0.0', + }, + }; + + rewritePackageJson(pkg, PackageManager.npm); + + expect(pkg.peerDependencies.vite).toBe('^7.0.0'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + expect(pkg.optionalDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect( + (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], + ).toBe('latest'); + }); + it('should preserve playwright when removing @vitest/browser-playwright', async () => { const pkg = { devDependencies: { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 23f9fad4c3..b447834a02 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1245,12 +1245,15 @@ function getCatalogDependencySpec( currentValue: string | undefined, version: string, supportCatalog: boolean, - options?: { allowFileProtocol?: boolean }, + options?: { dependencyField?: 'peerDependencies' }, ): string { + if ( + options?.dependencyField === 'peerDependencies' && + (!supportCatalog || version.startsWith('file:')) + ) { + return currentValue ?? version; + } if (!supportCatalog || version.startsWith('file:')) { - if (version.startsWith('file:') && options?.allowFileProtocol === false) { - return currentValue ?? version; - } return version; } return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; @@ -1560,7 +1563,7 @@ export function rewritePackageJson( for (const dependencies of dependencyGroups) { if (dependencies?.[key]) { dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { - allowFileProtocol: dependencies !== pkg.peerDependencies, + dependencyField: dependencies === pkg.peerDependencies ? 'peerDependencies' : undefined, }); needVitePlus = true; } From 790d4e212d176cc4c9bd4f3b313627ea70196a0c Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 13:12:10 +0800 Subject: [PATCH 05/12] fix(migration): preserve vite peer ranges --- .../snap.txt | 2 +- .../snap.txt | 2 +- .../src/migration/__tests__/migrator.spec.ts | 22 +++++++++++++++---- packages/cli/src/migration/migrator.ts | 5 +---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index b8d941ab89..b5b68c3425 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -42,7 +42,7 @@ export default defineConfig({ { "name": "my-vite-plugin", "peerDependencies": { - "vite": "catalog:" + "vite": "^6.0.0" }, "devDependencies": { "vite-plus": "catalog:" diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index b42682e4fb..819644c0d1 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -30,7 +30,7 @@ export default defineConfig({ { "name": "migration-skip-vite-peer-dependency", "peerDependencies": { - "vite": "catalog:" + "vite": "^6.0.0" }, "devDependencies": { "vite-plus": "catalog:" diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 9b59c79dde..750f5f03e8 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -196,7 +196,7 @@ describe('rewritePackageJson', () => { ).toBe('catalog:'); }); - it('preserves peer dependency ranges outside catalog mode', async () => { + it('preserves peer dependency ranges', async () => { const pkg = { peerDependencies: { vite: '^7.0.0', @@ -207,14 +207,28 @@ describe('rewritePackageJson', () => { }, }; - rewritePackageJson(pkg, PackageManager.npm); + rewritePackageJson(pkg, PackageManager.pnpm, true); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); - expect(pkg.optionalDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.optionalDependencies.vite).toBe('catalog:'); expect( (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], - ).toBe('latest'); + ).toBe('catalog:'); + + const npmPkg = { + peerDependencies: { + vite: '^7.0.0', + }, + optionalDependencies: { + vite: '^7.0.0', + }, + }; + + rewritePackageJson(npmPkg, PackageManager.npm); + + expect(npmPkg.peerDependencies.vite).toBe('^7.0.0'); + expect(npmPkg.optionalDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); }); it('should preserve playwright when removing @vitest/browser-playwright', async () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index b447834a02..5d1101be4e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1247,10 +1247,7 @@ function getCatalogDependencySpec( supportCatalog: boolean, options?: { dependencyField?: 'peerDependencies' }, ): string { - if ( - options?.dependencyField === 'peerDependencies' && - (!supportCatalog || version.startsWith('file:')) - ) { + if (options?.dependencyField === 'peerDependencies') { return currentValue ?? version; } if (!supportCatalog || version.startsWith('file:')) { From 3303f39c00a22b7ff260ed8f4ebc6cfa67d91dcd Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 13:19:33 +0800 Subject: [PATCH 06/12] fix(migration): avoid yarn optional catalog specs --- .../src/migration/__tests__/migrator.spec.ts | 19 +++++++++++ packages/cli/src/migration/migrator.ts | 34 ++++++++++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 750f5f03e8..ca175ee7e9 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -173,6 +173,25 @@ describe('rewritePackageJson', () => { expect((pkg.devDependencies as Record)['vite-plus']).toBe('catalog:'); }); + it('uses override specs for yarn optional dependencies in monorepo projects', async () => { + const pkg = { + devDependencies: { + vite: '^7.0.0', + }, + optionalDependencies: { + vite: '^7.0.0', + vitest: 'catalog:test', + }, + }; + + rewritePackageJson(pkg, PackageManager.yarn, true); + + expect(pkg.devDependencies.vite).toBe('catalog:'); + expect(pkg.optionalDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.optionalDependencies.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); + expect((pkg.devDependencies as Record)['vite-plus']).toBe('catalog:'); + }); + it('rewrites peer and optional dependency catalog specs in monorepo projects', async () => { const pkg = { peerDependencies: { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5d1101be4e..28e1262537 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -87,6 +87,12 @@ const BROWSER_PROVIDER_PEER_DEPS: Record = { '@vitest/browser-webdriverio': 'webdriverio', }; +type PackageJsonDependencyField = + | 'devDependencies' + | 'dependencies' + | 'peerDependencies' + | 'optionalDependencies'; + function warnMigration(message: string, report?: MigrationReport) { addMigrationWarning(report, message); if (!report) { @@ -1245,11 +1251,17 @@ function getCatalogDependencySpec( currentValue: string | undefined, version: string, supportCatalog: boolean, - options?: { dependencyField?: 'peerDependencies' }, + options?: { dependencyField?: PackageJsonDependencyField; packageManager?: PackageManager }, ): string { if (options?.dependencyField === 'peerDependencies') { return currentValue ?? version; } + if ( + options?.dependencyField === 'optionalDependencies' && + options?.packageManager === PackageManager.yarn + ) { + return version; + } if (!supportCatalog || version.startsWith('file:')) { return version; } @@ -1550,17 +1562,21 @@ export function rewritePackageJson( } const supportCatalog = !!isMonorepo && packageManager !== PackageManager.npm; let needVitePlus = false; - const dependencyGroups = [ - pkg.devDependencies, - pkg.dependencies, - pkg.peerDependencies, - pkg.optionalDependencies, + const dependencyGroups: { + dependencyField: PackageJsonDependencyField; + dependencies: Record | undefined; + }[] = [ + { dependencyField: 'devDependencies', dependencies: pkg.devDependencies }, + { dependencyField: 'dependencies', dependencies: pkg.dependencies }, + { dependencyField: 'peerDependencies', dependencies: pkg.peerDependencies }, + { dependencyField: 'optionalDependencies', dependencies: pkg.optionalDependencies }, ]; for (const [key, version] of Object.entries(VITE_PLUS_OVERRIDE_PACKAGES)) { - for (const dependencies of dependencyGroups) { + for (const { dependencyField, dependencies } of dependencyGroups) { if (dependencies?.[key]) { dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { - dependencyField: dependencies === pkg.peerDependencies ? 'peerDependencies' : undefined, + dependencyField, + packageManager, }); needVitePlus = true; } @@ -1569,7 +1585,7 @@ export function rewritePackageJson( // remove packages that are replaced with vite-plus for (const name of REMOVE_PACKAGES) { let wasRemoved = false; - for (const dependencies of dependencyGroups) { + for (const { dependencies } of dependencyGroups) { if (dependencies?.[name]) { delete dependencies[name]; wasRemoved = true; From 97ad0c769052543518768b8599dbfa0ca3607eca Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 13:35:30 +0800 Subject: [PATCH 07/12] revert: remove env pin test isolation changes --- .../vite_global_cli/src/commands/env/pin.rs | 50 +++++++------------ 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/pin.rs b/crates/vite_global_cli/src/commands/env/pin.rs index 6e326b0ea0..5f891dd05c 100644 --- a/crates/vite_global_cli/src/commands/env/pin.rs +++ b/crates/vite_global_cli/src/commands/env/pin.rs @@ -220,40 +220,12 @@ pub async fn do_unpin(cwd: &AbsolutePathBuf) -> Result { #[cfg(test)] mod tests { - use std::ffi::OsString; - use serial_test::serial; use tempfile::TempDir; use vite_path::AbsolutePathBuf; use super::*; - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: &std::path::Path) -> Self { - let original = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, original } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } - } - } - #[tokio::test] async fn test_show_pinned_no_file() { let temp_dir = TempDir::new().unwrap(); @@ -295,11 +267,9 @@ mod tests { } #[tokio::test] - #[serial] async fn test_do_unpin() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _vp_home = EnvVarGuard::set(vite_shared::env_vars::VP_HOME, temp_path.as_path()); // Create .node-version let node_version_path = temp_path.join(".node-version"); @@ -320,7 +290,10 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _vp_home = EnvVarGuard::set(vite_shared::env_vars::VP_HOME, temp_path.as_path()); + // Point VP_HOME to temp dir + unsafe { + std::env::set_var(vite_shared::env_vars::VP_HOME, temp_path.as_path()); + } // Create cache file manually let cache_dir = temp_path.join("cache"); @@ -343,6 +316,11 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after unpin" ); + + // Cleanup + unsafe { + std::env::remove_var(vite_shared::env_vars::VP_HOME); + } } // Run serially: mutates VP_HOME env var which affects invalidate_cache() @@ -352,7 +330,10 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _vp_home = EnvVarGuard::set(vite_shared::env_vars::VP_HOME, temp_path.as_path()); + // Point VP_HOME to temp dir + unsafe { + std::env::set_var(vite_shared::env_vars::VP_HOME, temp_path.as_path()); + } // Create cache file manually let cache_dir = temp_path.join("cache"); @@ -379,6 +360,11 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after pin" ); + + // Cleanup + unsafe { + std::env::remove_var(vite_shared::env_vars::VP_HOME); + } } #[tokio::test] From 72b1b5400dad726c76903cb546c5574465ff6aae Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 13:49:54 +0800 Subject: [PATCH 08/12] revert: remove test build cleanup change --- packages/test/build.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/test/build.ts b/packages/test/build.ts index 3c081b53c3..444963eec7 100644 --- a/packages/test/build.ts +++ b/packages/test/build.ts @@ -177,8 +177,6 @@ const CJS_REEXPORT_PACKAGES = new Set(['expect-type']); // Node built-in modules (including node: prefix variants) const NODE_BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); -await rm(distDir, { recursive: true, force: true }); - // Step 1: Copy vitest-dev dist files (rewriting vite -> core package) await bundleVitest(); From 084aff3cfa0963ba861891a3438d4e6d1458917b Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 14:09:15 +0800 Subject: [PATCH 09/12] test(migration): clarify vite peer snap cases --- .../migration-monorepo-skip-vite-peer-dependency/snap.txt | 6 +++--- .../migration-monorepo-skip-vite-peer-dependency/steps.json | 6 +++--- .../migration-skip-vite-peer-dependency/snap.txt | 6 +++--- .../migration-skip-vite-peer-dependency/steps.json | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index b5b68c3425..b43ddde6c5 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -1,9 +1,9 @@ -> vp migrate --no-interactive # migration should check each package's peerDependencies +> vp migrate --no-interactive # migration should preserve vite peer contracts in workspace packages ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied, 1 file had imports rewritten -> cat packages/vite-plugin/src/index.ts # vite-plugin has vite in peerDeps: vite NOT rewritten, vitest rewritten +> cat packages/vite-plugin/src/index.ts # vite-plugin has vite in peerDeps: vite imports stay public, vitest rewrites import { defineConfig, type Plugin } from 'vite'; import { describe, it, expect } from 'vite-plus/test'; @@ -38,7 +38,7 @@ export default defineConfig({ } } -> cat packages/vite-plugin/package.json # has vite in peerDependencies +> cat packages/vite-plugin/package.json # vite peer range is preserved { "name": "my-vite-plugin", "peerDependencies": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/steps.json b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/steps.json index 4cd2e5c2d3..1345b5fe0f 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/steps.json +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/steps.json @@ -1,8 +1,8 @@ { "commands": [ - "vp migrate --no-interactive # migration should check each package's peerDependencies", - "cat packages/vite-plugin/src/index.ts # vite-plugin has vite in peerDeps: vite NOT rewritten, vitest rewritten", + "vp migrate --no-interactive # migration should preserve vite peer contracts in workspace packages", + "cat packages/vite-plugin/src/index.ts # vite-plugin has vite in peerDeps: vite imports stay public, vitest rewrites", "cat package.json # check root package.json (no peerDependencies)", - "cat packages/vite-plugin/package.json # has vite in peerDependencies" + "cat packages/vite-plugin/package.json # vite peer range is preserved" ] } diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 819644c0d1..a6f4e6f3ac 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -1,9 +1,9 @@ -> vp migrate --no-interactive # migration should skip rewriting vite imports when vite is in peerDependencies +> vp migrate --no-interactive # migration should preserve vite peer contracts ◇ Migrated . to Vite+ • Node pnpm • 2 config updates applied, 1 file had imports rewritten -> cat src/index.ts # vite imports should NOT be rewritten, vitest imports SHOULD be rewritten +> cat src/index.ts # vite imports stay public, vitest imports rewrite import { defineConfig, type Plugin } from 'vite'; import { describe, it, expect } from 'vite-plus/test'; @@ -26,7 +26,7 @@ export default defineConfig({ plugins: [myVitePlugin()], }); -> cat package.json # check package.json +> cat package.json # vite peer range is preserved { "name": "migration-skip-vite-peer-dependency", "peerDependencies": { diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/steps.json b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/steps.json index cca9527700..62c8710a1a 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/steps.json +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/steps.json @@ -1,8 +1,8 @@ { "commands": [ - "vp migrate --no-interactive # migration should skip rewriting vite imports when vite is in peerDependencies", - "cat src/index.ts # vite imports should NOT be rewritten, vitest imports SHOULD be rewritten", - "cat package.json # check package.json", + "vp migrate --no-interactive # migration should preserve vite peer contracts", + "cat src/index.ts # vite imports stay public, vitest imports rewrite", + "cat package.json # vite peer range is preserved", "cat pnpm-workspace.yaml # check pnpm-workspace.yaml has overrides and catalog" ] } From 0cef4510a563982b60a594a161dbd3400ede245a Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 14:20:59 +0800 Subject: [PATCH 10/12] fix: preserve peer catalog contracts during migration --- .../bun-catalog-file-protocol.spec.ts | 2 +- .../src/migration/__tests__/migrator.spec.ts | 89 +++++++++- packages/cli/src/migration/migrator.ts | 163 ++++++++++++++++-- 3 files changed, 239 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index f6706339af..e4050a529b 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -141,7 +141,7 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { rewritePackageJson(pkg, PackageManager.pnpm, true); expect(pkg.peerDependencies.vite).toBe('^7.0.0'); - expect(pkg.peerDependencies.vitest).toBe('catalog:test'); + expect(pkg.peerDependencies.vitest).toBe('*'); expect(pkg.optionalDependencies.vite).toBe( 'file:/tmp/tgz/voidzero-dev-vite-plus-core-0.0.0.tgz', ); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index ca175ee7e9..6593ed3e3a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -206,7 +206,7 @@ describe('rewritePackageJson', () => { rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.peerDependencies.vite).toBe('catalog:vite7'); + expect(pkg.peerDependencies.vite).toBe('*'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); expect(pkg.optionalDependencies.vitest).toBe('catalog:test'); expect(pkg.optionalDependencies).not.toHaveProperty('oxlint'); @@ -685,7 +685,11 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { JSON.stringify({ name: 'test', devDependencies: { vite: 'catalog:vite7' }, - peerDependencies: { tsdown: 'catalog:test' }, + peerDependencies: { + vite: 'catalog:vite7', + vitest: 'catalog:', + tsdown: 'catalog:test', + }, }), ); fs.writeFileSync( @@ -693,6 +697,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { [ 'overrides:', ' vite: catalog:vite7', + 'catalog:', + ' vitest: ^4.0.0', 'catalogs:', ' vite7:', ' react: ^18.0.0', @@ -708,11 +714,13 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + catalog: Record; overrides: Record; catalogs: Record>; }; expect(yaml.overrides.vite).toBe('catalog:vite7'); expect(yaml.overrides.vitest).toBe('catalog:'); + expect(yaml.catalog.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); expect(yaml.catalogs.vite7.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(yaml.catalogs.vite7.react).toBe('^18.0.0'); expect(yaml.catalogs.vite7['vite-plus']).toBe('latest'); @@ -726,6 +734,8 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); expect(pkg.devDependencies['vite-plus']).toBe('catalog:'); + expect(pkg.peerDependencies.vite).toBe('^7.0.0'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); expect(pkg.peerDependencies).not.toHaveProperty('tsdown'); }); @@ -765,6 +775,38 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }; expect(pkg.pnpm).toBeUndefined(); }); + + it('does not resolve peer dependency catalog specs to migrated aliases', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test', + peerDependencies: { + vite: 'catalog:vite7', + vitest: 'catalog:', + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'catalog:', + ' vitest: npm:@voidzero-dev/vite-plus-test@latest', + 'catalogs:', + ' vite7:', + ' vite: npm:@voidzero-dev/vite-plus-core@latest', + '', + ].join('\n'), + ); + + rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + peerDependencies: Record; + }; + expect(pkg.peerDependencies.vite).toBe('*'); + expect(pkg.peerDependencies.vitest).toBe('*'); + }); }); describe('rewriteMonorepo yarn catalog', () => { @@ -785,6 +827,7 @@ describe('rewriteMonorepo yarn catalog', () => { name: 'yarn-monorepo', workspaces: ['packages/*'], devDependencies: { vite: 'catalog:vite7' }, + peerDependencies: { vite: 'catalog:vite7', vitest: 'catalog:test' }, packageManager: 'yarn@4.10.0', }), ); @@ -816,8 +859,11 @@ describe('rewriteMonorepo yarn catalog', () => { const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; + peerDependencies: Record; }; expect(pkg.devDependencies.vite).toBe('catalog:vite7'); + expect(pkg.peerDependencies.vite).toBe('^7.0.0'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); }); @@ -881,6 +927,41 @@ describe('rewriteMonorepo bun catalog', () => { expect(workspaces.packages).toEqual(['packages/*']); }); + it('cleans stale top-level bun catalog when workspaces.catalog is preferred', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-monorepo', + workspaces: { + packages: ['packages/*'], + catalog: { vite: '^7.0.0' }, + }, + catalog: { + vite: '^6.0.0', + vitest: '^3.0.0', + tsdown: '^0.1.0', + react: '^19.0.0', + }, + devDependencies: { vite: '^7.0.0' }, + packageManager: 'bun@1.3.11', + }), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun), true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + catalog: Record; + workspaces: { catalog: Record }; + }; + expect(pkg.workspaces.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.workspaces.catalog['vite-plus']).toBe('latest'); + expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.catalog.vitest).toBe('npm:@voidzero-dev/vite-plus-test@latest'); + expect(pkg.catalog.tsdown).toBeUndefined(); + expect(pkg.catalog.react).toBe('^19.0.0'); + expect(pkg.catalog['vite-plus']).toBeUndefined(); + }); + it('writes catalog to top-level when workspaces is an object without catalog', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), @@ -917,6 +998,7 @@ describe('rewriteMonorepo bun catalog', () => { }, overrides: { vite: 'catalog:build' }, devDependencies: { vite: 'catalog:build' }, + peerDependencies: { vite: 'catalog:build', vitest: 'catalog:test' }, packageManager: 'bun@1.3.11', }), ); @@ -928,6 +1010,7 @@ describe('rewriteMonorepo bun catalog', () => { catalogs: Record>; overrides: Record; devDependencies: Record; + peerDependencies: Record; }; expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); expect(pkg.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); @@ -937,6 +1020,8 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.overrides.vite).toBe('catalog:build'); expect(pkg.overrides.vitest).toBe('catalog:'); expect(pkg.devDependencies.vite).toBe('catalog:build'); + expect(pkg.peerDependencies.vite).toBe('^7.0.0'); + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); }); it('rewrites workspaces named catalogs and writes default catalog beside them', () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 28e1262537..9702079254 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -35,7 +35,7 @@ import { removeDeprecatedTsconfigFalseOption, } from '../utils/tsconfig.ts'; import type { NpmWorkspaces } from '../utils/workspace.ts'; -import { editYamlFile, scalarString, type YamlDocument } from '../utils/yaml.ts'; +import { editYamlFile, readYamlFile, scalarString, type YamlDocument } from '../utils/yaml.ts'; import { PRETTIER_CONFIG_FILES, PRETTIER_PACKAGE_JSON_CONFIG, @@ -87,12 +87,22 @@ const BROWSER_PROVIDER_PEER_DEPS: Record = { '@vitest/browser-webdriverio': 'webdriverio', }; +const PUBLIC_PEER_DEPENDENCY_FALLBACKS: Record = { + vite: '*', + vitest: '*', +}; + type PackageJsonDependencyField = | 'devDependencies' | 'dependencies' | 'peerDependencies' | 'optionalDependencies'; +type CatalogDependencyResolver = ( + catalogSpec: string, + dependencyName: string, +) => string | undefined; + function warnMigration(message: string, report?: MigrationReport) { addMigrationWarning(report, message); if (!report) { @@ -803,8 +813,11 @@ export function rewriteStandaloneProject( } const packageManager = workspaceInfo.packageManager; + const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); let extractedStagedConfig: Record | null = null; let remainingPnpmOverrides: Record | undefined; + let shouldRewritePnpmWorkspaceYaml = false; + let shouldAddPnpmWorkspaceVitePlusOverride = false; // Determined inside editJsonFile callback to avoid a redundant file read let usePnpmWorkspaceYaml = false; editJsonFile<{ @@ -812,6 +825,8 @@ export function rewriteStandaloneProject( resolutions?: Record; devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; scripts?: Record; pnpm?: { overrides?: Record; @@ -836,14 +851,8 @@ export function rewriteStandaloneProject( // otherwise use pnpm-workspace.yaml. usePnpmWorkspaceYaml = !pkg.pnpm; if (usePnpmWorkspaceYaml) { - rewritePnpmWorkspaceYaml(projectPath); - // In force-override mode, also override vite-plus itself so transitive - // deps resolve to the local tgz instead of the published version. - if (isForceOverrideMode()) { - migratePnpmOverridesToWorkspaceYaml(projectPath, { - [VITE_PLUS_NAME]: VITE_PLUS_VERSION, - }); - } + shouldRewritePnpmWorkspaceYaml = true; + shouldAddPnpmWorkspaceVitePlusOverride = isForceOverrideMode(); } const overrideKeys = Object.keys(VITE_PLUS_OVERRIDE_PACKAGES); if (!usePnpmWorkspaceYaml) { @@ -892,6 +901,7 @@ export function rewriteStandaloneProject( packageManager, usePnpmWorkspaceYaml, skipStagedMigration, + catalogDependencyResolver, ); // ensure vite-plus is in devDependencies @@ -908,11 +918,21 @@ export function rewriteStandaloneProject( return pkg; }); + if (shouldRewritePnpmWorkspaceYaml) { + rewritePnpmWorkspaceYaml(projectPath); + } + // Move remaining non-Vite pnpm.overrides to pnpm-workspace.yaml if (remainingPnpmOverrides) { migratePnpmOverridesToWorkspaceYaml(projectPath, remainingPnpmOverrides); } + if (shouldAddPnpmWorkspaceVitePlusOverride) { + migratePnpmOverridesToWorkspaceYaml(projectPath, { + [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + }); + } + if (packageManager === PackageManager.yarn) { rewriteYarnrcYml(projectPath); } @@ -948,6 +968,10 @@ export function rewriteMonorepo( silent = false, report?: MigrationReport, ): void { + const catalogDependencyResolver = createCatalogDependencyResolver( + workspaceInfo.rootDir, + workspaceInfo.packageManager, + ); // rewrite root workspace if (workspaceInfo.packageManager === PackageManager.pnpm) { rewritePnpmWorkspaceYaml(workspaceInfo.rootDir); @@ -960,6 +984,7 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packageManager, skipStagedMigration, + catalogDependencyResolver, ); // rewrite packages @@ -970,6 +995,7 @@ export function rewriteMonorepo( skipStagedMigration, silent, report, + catalogDependencyResolver, ); } @@ -997,6 +1023,7 @@ export function rewriteMonorepoProject( skipStagedMigration?: boolean, silent = false, report?: MigrationReport, + catalogDependencyResolver?: CatalogDependencyResolver, ): void { cleanupDeprecatedTsconfigOptions(projectPath, silent, report); mergeViteConfigFiles(projectPath, silent, report); @@ -1011,10 +1038,18 @@ export function rewriteMonorepoProject( editJsonFile<{ devDependencies?: Record; dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; scripts?: Record; }>(packageJsonPath, (pkg) => { // rewrite scripts in package.json - extractedStagedConfig = rewritePackageJson(pkg, packageManager, true, skipStagedMigration); + extractedStagedConfig = rewritePackageJson( + pkg, + packageManager, + true, + skipStagedMigration, + catalogDependencyResolver, + ); return pkg; }); @@ -1251,9 +1286,21 @@ function getCatalogDependencySpec( currentValue: string | undefined, version: string, supportCatalog: boolean, - options?: { dependencyField?: PackageJsonDependencyField; packageManager?: PackageManager }, + options?: { + dependencyField?: PackageJsonDependencyField; + dependencyName?: string; + packageManager?: PackageManager; + catalogDependencyResolver?: CatalogDependencyResolver; + }, ): string { if (options?.dependencyField === 'peerDependencies') { + if (currentValue?.startsWith('catalog:') && options.dependencyName) { + const resolved = options.catalogDependencyResolver?.(currentValue, options.dependencyName); + if (resolved && !isVitePlusOverrideSpec(resolved)) { + return resolved; + } + return PUBLIC_PEER_DEPENDENCY_FALLBACKS[options.dependencyName] ?? currentValue; + } return currentValue ?? version; } if ( @@ -1268,6 +1315,78 @@ function getCatalogDependencySpec( return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; } +function isVitePlusOverrideSpec(value: string): boolean { + return ( + Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || + value.startsWith('npm:@voidzero-dev/vite-plus-') + ); +} + +function createCatalogDependencyResolver( + projectPath: string, + packageManager: PackageManager, +): CatalogDependencyResolver | undefined { + if (packageManager === PackageManager.pnpm) { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + return undefined; + } + const doc = readYamlFile(pnpmWorkspaceYamlPath) as { + catalog?: Record; + catalogs?: Record>; + } | null; + return createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); + } + if (packageManager === PackageManager.yarn) { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + if (!fs.existsSync(yarnrcYmlPath)) { + return undefined; + } + const doc = readYamlFile(yarnrcYmlPath) as { + catalog?: Record; + catalogs?: Record>; + } | null; + return createCatalogDependencyResolverFromCatalogs(doc?.catalog, doc?.catalogs); + } + if (packageManager === PackageManager.bun) { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return undefined; + } + const pkg = readJsonFile(packageJsonPath) as { + workspaces?: NpmWorkspaces; + catalog?: Record; + catalogs?: Record>; + }; + const workspacesObj = + pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined; + return (catalogSpec, dependencyName) => { + const catalogName = catalogSpec.slice('catalog:'.length); + if (catalogName) { + return ( + workspacesObj?.catalogs?.[catalogName]?.[dependencyName] ?? + pkg.catalogs?.[catalogName]?.[dependencyName] + ); + } + return workspacesObj?.catalog?.[dependencyName] ?? pkg.catalog?.[dependencyName]; + }; + } + return undefined; +} + +function createCatalogDependencyResolverFromCatalogs( + catalog: Record | undefined, + catalogs: Record> | undefined, +): CatalogDependencyResolver { + return (catalogSpec, dependencyName) => { + const catalogName = catalogSpec.slice('catalog:'.length); + if (catalogName) { + return catalogs?.[catalogName]?.[dependencyName]; + } + return catalog?.[dependencyName]; + }; +} + function getYamlMapScalarStringValue(map: unknown, key: string): string | undefined { if (!(map instanceof YAMLMap)) { return undefined; @@ -1385,8 +1504,14 @@ function rewriteBunCatalog(projectPath: string): void { if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; + if (pkg.catalog) { + rewriteCatalogObject(pkg.catalog, false); + } } else { pkg.catalog = catalog; + if (workspacesObj?.catalog) { + rewriteCatalogObject(workspacesObj.catalog, false); + } } if (workspacesObj?.catalogs) { rewriteCatalogsObject(workspacesObj.catalogs); @@ -1414,6 +1539,7 @@ function rewriteRootWorkspacePackageJson( projectPath: string, packageManager: PackageManager, skipStagedMigration?: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, ): void { const packageJsonPath = path.join(projectPath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { @@ -1425,6 +1551,9 @@ function rewriteRootWorkspacePackageJson( resolutions?: Record; overrides?: Record; devDependencies?: Record; + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; pnpm?: { overrides?: Record; peerDependencyRules?: { @@ -1499,7 +1628,14 @@ function rewriteRootWorkspacePackageJson( } // rewrite package.json - rewriteMonorepoProject(projectPath, packageManager, skipStagedMigration); + rewriteMonorepoProject( + projectPath, + packageManager, + skipStagedMigration, + undefined, + undefined, + catalogDependencyResolver, + ); } const RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml'); @@ -1541,6 +1677,7 @@ export function rewritePackageJson( packageManager: PackageManager, isMonorepo?: boolean, skipStagedMigration?: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, ): Record | null { if (pkg.scripts) { const updated = rewriteScripts( @@ -1576,7 +1713,9 @@ export function rewritePackageJson( if (dependencies?.[key]) { dependencies[key] = getCatalogDependencySpec(dependencies[key], version, supportCatalog, { dependencyField, + dependencyName: key, packageManager, + catalogDependencyResolver, }); needVitePlus = true; } From 5f8c561db5c9f78257b4707cbb74edfbe15ef143 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 15:23:36 +0800 Subject: [PATCH 11/12] fix: inject vitest for peer-only plugin packages --- .../src/migration/__tests__/migrator.spec.ts | 21 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 8 ++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 6593ed3e3a..585e70dc75 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -250,6 +250,27 @@ describe('rewritePackageJson', () => { expect(npmPkg.optionalDependencies.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); }); + it('adds local vitest when only a peer vitest exists for vitest-adjacent packages', async () => { + const pkg = { + dependencies: { + 'vitest-browser-svelte': '^1.0.0', + }, + peerDependencies: { + vitest: '^4.0.0', + }, + }; + + rewritePackageJson(pkg, PackageManager.pnpm, true); + + expect(pkg.peerDependencies.vitest).toBe('^4.0.0'); + expect((pkg as { devDependencies?: Record }).devDependencies?.vitest).toBe( + 'catalog:', + ); + expect( + (pkg as { devDependencies?: Record }).devDependencies?.['vite-plus'], + ).toBe('catalog:'); + }); + it('should preserve playwright when removing @vitest/browser-playwright', async () => { const pkg = { devDependencies: { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 9702079254..747b88ed6d 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1759,13 +1759,15 @@ export function rewritePackageJson( // on vitest (e.g., vitest-browser-svelte). Without this, pnpm resolves the real // vitest for peer deps instead of @voidzero-dev/vite-plus-test, causing // third-party type augmentations to target the wrong module. - const allDeps = { + const installableDeps = { ...pkg.dependencies, ...pkg.devDependencies, - ...pkg.peerDependencies, ...pkg.optionalDependencies, }; - if (!allDeps.vitest && Object.keys(allDeps).some((name) => name.includes('vitest'))) { + if ( + !installableDeps.vitest && + Object.keys(installableDeps).some((name) => name.includes('vitest')) + ) { const ver = VITE_PLUS_OVERRIDE_PACKAGES.vitest; pkg.devDependencies.vitest = getCatalogDependencySpec(undefined, ver, supportCatalog); } From 86923c3be441cab7c9b0d2ae66ca19473ce43f1d Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 28 Apr 2026 17:48:18 +0800 Subject: [PATCH 12/12] fix: preserve default pnpm catalog overrides --- .../src/migration/__tests__/migrator.spec.ts | 37 +++++++++++++++++++ packages/cli/src/migration/migrator.ts | 10 ++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 585e70dc75..cc27497d35 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -797,6 +797,43 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { expect(pkg.pnpm).toBeUndefined(); }); + it('preserves default pnpm catalog overrides over stale workspace named overrides', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'pnpm-monorepo', + workspaces: ['packages/*'], + devDependencies: { vite: 'catalog:' }, + pnpm: { + overrides: { + vite: 'catalog:', + }, + }, + }), + ); + fs.writeFileSync( + path.join(tmpDir, 'pnpm-workspace.yaml'), + [ + 'packages:', + ' - packages/*', + 'overrides:', + ' vite: catalog:vite7', + 'catalogs:', + ' vite7:', + ' vite: ^7.0.0', + '', + ].join('\n'), + ); + + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true); + + const yaml = readYamlObject(path.join(tmpDir, 'pnpm-workspace.yaml')) as { + overrides: Record; + }; + expect(yaml.overrides.vite).toBe('catalog:'); + expect(yaml.overrides.vitest).toBe('catalog:'); + }); + it('does not resolve peer dependency catalog specs to migrated aliases', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 747b88ed6d..a228a1db1a 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1169,13 +1169,13 @@ function cleanupPnpmOverridesForWorkspaceYaml( overrideKeys: string[], ): Record | undefined { // Remove Vite-managed keys from pnpm.overrides - const namedCatalogOverrides: Record = {}; + const catalogOverrides: Record = {}; const overrides = pkg.pnpm?.overrides; for (const key of [...overrideKeys, ...REMOVE_PACKAGES]) { const value = overrides?.[key]; if (value) { - if (overrideKeys.includes(key) && value.startsWith('catalog:') && value !== 'catalog:') { - namedCatalogOverrides[key] = value; + if (overrideKeys.includes(key) && value.startsWith('catalog:')) { + catalogOverrides[key] = value; } delete overrides[key]; } @@ -1192,8 +1192,8 @@ function cleanupPnpmOverridesForWorkspaceYaml( // Collect remaining overrides to move to pnpm-workspace.yaml then delete all // (pnpm ignores workspace-level overrides when pnpm.overrides exists in package.json) let remaining: Record | undefined; - if (Object.keys(namedCatalogOverrides).length > 0) { - remaining = { ...namedCatalogOverrides }; + if (Object.keys(catalogOverrides).length > 0) { + remaining = { ...catalogOverrides }; } if (pkg.pnpm?.overrides && Object.keys(pkg.pnpm.overrides).length > 0) { remaining = { ...remaining, ...pkg.pnpm.overrides };