From d230f70b81c3557cd743b5ea3310ac3a57ec101b Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 13:55:46 +0900 Subject: [PATCH 1/7] fix(migrate): keep bun catalog specs when workspaces.packages is empty Bun resolves catalog: references whenever the root package.json has a workspaces field, even an empty one. The upgrade path required a non-empty package list, so a single-package Bun project with workspaces.catalog had its catalog: specs rewritten to concrete versions. Fixes #3375 --- .../package.json | 25 ++++++++ .../snapshots.toml | 9 +++ ...on_upgrade_bun_empty_workspaces_catalog.md | 59 +++++++++++++++++++ .../src/migration/__tests__/migrator.spec.ts | 32 +++++++++- .../migration/migrator/vite-plus-bootstrap.ts | 25 ++++---- 5 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots/migration_upgrade_bun_empty_workspaces_catalog.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/package.json new file mode 100644 index 0000000000..f1a77611c5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/package.json @@ -0,0 +1,25 @@ +{ + "name": "migration-upgrade-bun-empty-workspaces-catalog", + "devDependencies": { + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "overrides": { + "vite": "catalog:", + "vitest": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "1.4.0", + "onFail": "download" + } + }, + "workspaces": { + "packages": [], + "catalog": { + "vite-plus": "^0.1.20", + "vitest": "4.1.9" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots.toml new file mode 100644 index 0000000000..d556a54dea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "migration_upgrade_bun_empty_workspaces_catalog" +vp = "global" +comment = "Bun resolves catalogs whenever the root package.json has a `workspaces` field, even with an empty package list (#3375)." +steps = [ + { argv = ["vp", "migrate", "--no-interactive"], comment = "a single-package bun project with an empty workspaces.packages keeps its catalog", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "toolchain specs stay catalog: and the workspaces.catalog is rewritten in place", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "a second run converges to the already-migrated fast exit", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots/migration_upgrade_bun_empty_workspaces_catalog.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots/migration_upgrade_bun_empty_workspaces_catalog.md new file mode 100644 index 0000000000..539c32d97a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_empty_workspaces_catalog/snapshots/migration_upgrade_bun_empty_workspaces_catalog.md @@ -0,0 +1,59 @@ +# migration_upgrade_bun_empty_workspaces_catalog + +Bun resolves catalogs whenever the root package.json has a `workspaces` field, even with an empty package list (#3375). + +## `vp migrate --no-interactive` + +a single-package bun project with an empty workspaces.packages keeps its catalog + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node bun +• Dependencies: + vite-plus 0.1.20 → + vite → +• Package manager settings configured +``` + +## `vpt print-file package.json` + +toolchain specs stay catalog: and the workspaces.catalog is rewritten in place + +``` +{ + "name": "migration-upgrade-bun-empty-workspaces-catalog", + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "overrides": { + "vite": "catalog:" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "", + "onFail": "download" + } + }, + "workspaces": { + "packages": [], + "catalog": { + "vite-plus": "", + "vite": "npm:@voidzero-dev/vite-plus-core@" + } + } +} +``` + +## `vp migrate --no-interactive` + +a second run converges to the already-migrated fast exit + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c1bcb66131..1bace4709c 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2048,7 +2048,7 @@ describe('ensureVitePlusBootstrap', () => { // Regression for the OneSignal-Website-SDK upgrade: a standalone // (non-workspace) bun project already on Vite+ with concrete specs. Bun // resolves `catalog:` references ONLY inside a workspace (a root - // package.json with a non-empty `workspaces`), so converting these edges to + // package.json with a `workspaces` field), so converting these edges to // `catalog:` and writing a top-level `catalog` field makes `bun install` // abort with "vite@catalog: failed to resolve". The upgrade path must keep // every managed spec concrete and write NO catalog field, mirroring the @@ -2106,6 +2106,36 @@ describe('ensureVitePlusBootstrap', () => { expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(false); }); + it('keeps `catalog:` specs in a bun project whose `workspaces.packages` is empty (#3375)', () => { + // Bun resolves catalogs whenever the root has a `workspaces` field, even an + // empty one, so a single-package project may keep its toolchain there. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-root-catalog', + devDependencies: { vite: 'catalog:', 'vite-plus': 'catalog:' }, + overrides: { vite: 'catalog:' }, + workspaces: { + packages: [], + catalog: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, 'vite-plus': 'latest' }, + }, + devEngines: { + packageManager: { name: 'bun', version: '1.4.4', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(false); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun)); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + }; + expect(pkg.devDependencies).toEqual({ vite: 'catalog:', 'vite-plus': 'catalog:' }); + expect(pkg.overrides).toEqual({ vite: 'catalog:' }); + }); + it('rewrites a STANDALONE bun `catalog:` override to the concrete core alias (catalogs unsupported)', () => { // Regression: a standalone (non-workspace) bun project already on Vite+ that // carries `overrides.vite: "catalog:"` plus a matching top-level `catalog`. diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index 5a4105b168..9ff9d41dd9 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -68,21 +68,16 @@ import { } from './shared.ts'; // Bun resolves `catalog:` references (and a top-level `catalog` field) ONLY -// inside a workspace: a root package.json declaring a non-empty `workspaces` -// (the array form, or the object `{ packages: [...] }` form). A standalone bun -// project has nowhere to resolve a catalog from, so converting its edges to -// `catalog:` makes `bun install` abort with "vite@catalog: failed to resolve". +// inside a workspace: a root package.json with a `workspaces` field. An empty +// one (`[]` or `{ packages: [] }`) is enough (verified on bun 1.4.4, #3375). A +// standalone bun project has nowhere to resolve a catalog from, so converting +// its edges to `catalog:` makes `bun install` abort with "vite@catalog: failed +// to resolve". // The upgrade path must keep standalone bun on concrete specs, mirroring the // fresh path: `supportCatalog` is false for standalone bun (orchestrators.ts) // and `rewriteBunCatalog` runs only on monorepo roots (orchestrators.ts). -function bunWorkspaceDeclaresPackages(workspaces: NpmWorkspaces | undefined): boolean { - if (Array.isArray(workspaces)) { - return workspaces.length > 0; - } - if (workspaces && typeof workspaces === 'object') { - return Array.isArray(workspaces.packages) && workspaces.packages.length > 0; - } - return false; +function bunHasWorkspacesField(workspaces: NpmWorkspaces | undefined): boolean { + return workspaces != null; } export type BootstrapPackageJson = { @@ -676,7 +671,7 @@ export function detectVitePlusBootstrapPending( supportsCatalog( packageManager, resolvedPackageManagerVersion, - packageManager === PackageManager.bun && bunWorkspaceDeclaresPackages(pkg.workspaces), + packageManager === PackageManager.bun && bunHasWorkspacesField(pkg.workspaces), ); const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const canonicalVitePlusSpec = supportCatalog @@ -979,13 +974,13 @@ export function ensureVitePlusBootstrap( workspaceInfo.packageManager === PackageManager.pnpm && pnpmSupportsWorkspaceSettings(workspaceInfo.downloadPackageManager.version); // Catalogs only resolve inside a bun WORKSPACE (a root package.json with a - // non-empty `workspaces`). Gate both the catalog edges (`supportCatalog`) and + // `workspaces` field, even an empty one). Gate both the catalog edges (`supportCatalog`) and // the `rewriteBunCatalog` sink below on this so a standalone bun project keeps // concrete specs and writes no catalog field — otherwise `bun install` aborts // with "vite@catalog: failed to resolve". const isBunWorkspace = workspaceInfo.packageManager === PackageManager.bun && - bunWorkspaceDeclaresPackages( + bunHasWorkspacesField( (readJsonFile(packageJsonPath) as { workspaces?: NpmWorkspaces }).workspaces, ); const supportCatalog = From 89c9c0911588b0a50ec34c76a3706af4325ce6e1 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 14:17:17 +0900 Subject: [PATCH 2/7] fix(migrate): require bun 1.2.21 for a top-level catalog Bun before 1.2.21 reads only workspaces.catalog, so a top-level catalog fails to resolve there. Keep concrete specs on those versions unless the project already has a workspaces.catalog, which rewriteBunCatalog keeps in place. --- .../src/migration/__tests__/migrator.spec.ts | 36 +++++++++++++++++-- .../cli/src/migration/migrator/catalog.ts | 8 +++++ .../migration/migrator/vite-plus-bootstrap.ts | 26 +++++++++----- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 1bace4709c..c1728debe7 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2108,7 +2108,8 @@ describe('ensureVitePlusBootstrap', () => { it('keeps `catalog:` specs in a bun project whose `workspaces.packages` is empty (#3375)', () => { // Bun resolves catalogs whenever the root has a `workspaces` field, even an - // empty one, so a single-package project may keep its toolchain there. + // empty one, so a single-package project may keep its toolchain there. A + // `workspaces.catalog` resolves on every catalog-capable bun, including 1.2.14. fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -2120,13 +2121,13 @@ describe('ensureVitePlusBootstrap', () => { catalog: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, 'vite-plus': 'latest' }, }, devEngines: { - packageManager: { name: 'bun', version: '1.4.4', onFail: 'download' }, + packageManager: { name: 'bun', version: '1.2.14', onFail: 'download' }, }, }), ); expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(false); - ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun)); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.2.14')); const pkg = readJson(path.join(tmpDir, 'package.json')) as { devDependencies: Record; @@ -2136,6 +2137,35 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.overrides).toEqual({ vite: 'catalog:' }); }); + it('keeps concrete specs on bun < 1.2.21, which ignores a top-level catalog', () => { + // Without an existing `workspaces.catalog`, rewriteBunCatalog would write a + // top-level `catalog`, which bun 1.2.14 to 1.2.20 cannot resolve. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'old-bun', + workspaces: [], + devDependencies: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, 'vite-plus': 'latest' }, + devEngines: { + packageManager: { name: 'bun', version: '1.2.20', onFail: 'download' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.2.20')); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + catalog?: Record; + }; + expect(pkg.devDependencies).toEqual({ + vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, + 'vite-plus': 'latest', + }); + expect(pkg.catalog).toBeUndefined(); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(false); + }); + it('rewrites a STANDALONE bun `catalog:` override to the concrete core alias (catalogs unsupported)', () => { // Regression: a standalone (non-workspace) bun project already on Vite+ that // carries `overrides.vite: "catalog:"` plus a matching top-level `catalog`. diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index cd6b13e998..395a6e6bdc 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -124,6 +124,14 @@ export function yarnSupportsCatalog(version: string): boolean { return versionAtLeast(version, YARN_CATALOG_MIN_VERSION, ['latest', 'next', 'stable']); } +const BUN_TOP_LEVEL_CATALOG_MIN_VERSION = '1.2.21'; + +// Bun before 1.2.21 reads only `workspaces.catalog`, so a top-level `catalog` +// fails with "catalog: failed to resolve" there (verified on 1.2.14 to 1.2.20). +export function bunSupportsTopLevelCatalog(version: string): boolean { + return versionAtLeast(version, BUN_TOP_LEVEL_CATALOG_MIN_VERSION, ['latest', 'canary']); +} + // Whether a `catalog:` reference resolves for this package manager and version: // pnpm >= 9.5.0, Yarn >= 4.10.0, bun only inside a workspace, npm never. The // force-override `file:` guard is layered on by the bootstrap callers. diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index 9ff9d41dd9..567127c6bf 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -32,6 +32,7 @@ import { pnpmPackageJsonSettingsPending, pnpmSupportsWorkspaceSettings, supportsCatalog, + bunSupportsTopLevelCatalog, pnpmWorkspaceMinimumReleaseAgeExemptionsPending, projectUsesVitestDirectly, pruneLegacyWrapperAliases, @@ -69,15 +70,22 @@ import { // Bun resolves `catalog:` references (and a top-level `catalog` field) ONLY // inside a workspace: a root package.json with a `workspaces` field. An empty -// one (`[]` or `{ packages: [] }`) is enough (verified on bun 1.4.4, #3375). A -// standalone bun project has nowhere to resolve a catalog from, so converting -// its edges to `catalog:` makes `bun install` abort with "vite@catalog: failed -// to resolve". +// one (`[]` or `{ packages: [] }`) is enough (#3375). A standalone bun project +// has nowhere to resolve a catalog from, so converting its edges to `catalog:` +// makes `bun install` abort with "vite@catalog: failed to resolve". Bun before +// 1.2.21 also ignores a top-level `catalog`, so it needs an existing +// `workspaces.catalog`, which `rewriteBunCatalog` keeps in place. // The upgrade path must keep standalone bun on concrete specs, mirroring the // fresh path: `supportCatalog` is false for standalone bun (orchestrators.ts) // and `rewriteBunCatalog` runs only on monorepo roots (orchestrators.ts). -function bunHasWorkspacesField(workspaces: NpmWorkspaces | undefined): boolean { - return workspaces != null; +function bunSupportsCatalog(workspaces: NpmWorkspaces | undefined, version: string): boolean { + if (workspaces == null) { + return false; + } + return ( + (!Array.isArray(workspaces) && workspaces.catalog != null) || + bunSupportsTopLevelCatalog(version) + ); } export type BootstrapPackageJson = { @@ -671,7 +679,8 @@ export function detectVitePlusBootstrapPending( supportsCatalog( packageManager, resolvedPackageManagerVersion, - packageManager === PackageManager.bun && bunHasWorkspacesField(pkg.workspaces), + packageManager === PackageManager.bun && + bunSupportsCatalog(pkg.workspaces, resolvedPackageManagerVersion), ); const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const canonicalVitePlusSpec = supportCatalog @@ -980,8 +989,9 @@ export function ensureVitePlusBootstrap( // with "vite@catalog: failed to resolve". const isBunWorkspace = workspaceInfo.packageManager === PackageManager.bun && - bunHasWorkspacesField( + bunSupportsCatalog( (readJsonFile(packageJsonPath) as { workspaces?: NpmWorkspaces }).workspaces, + workspaceInfo.downloadPackageManager.version, ); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && From f99889cbe9a4efddeb9868f13a1db28f094242c0 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 14:21:00 +0900 Subject: [PATCH 3/7] test(migrate): snapshot bun 1.2.20 upgrade without a top-level catalog --- .../package.json | 19 +++++++ .../snapshots.toml | 9 ++++ ...on_upgrade_bun_old_no_top_level_catalog.md | 53 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json new file mode 100644 index 0000000000..db304bd6b2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json @@ -0,0 +1,19 @@ +{ + "name": "migration-upgrade-bun-old-no-top-level-catalog", + "workspaces": [], + "devDependencies": { + "vite-plus": "^0.1.20", + "vitest": "4.1.9" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@0.1.20", + "vitest": "4.1.9" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "1.2.20", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml new file mode 100644 index 0000000000..f5b5ee9375 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "migration_upgrade_bun_old_no_top_level_catalog" +vp = "global" +comment = "Bun before 1.2.21 reads only `workspaces.catalog`, so a top-level `catalog` would fail to resolve." +steps = [ + { argv = ["vp", "migrate", "--no-interactive"], comment = "an empty-workspaces bun 1.2.20 project without a catalog upgrades", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "toolchain specs stay concrete and no top-level catalog is written", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "a second run converges to the already-migrated fast exit", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md new file mode 100644 index 0000000000..84158929e9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md @@ -0,0 +1,53 @@ +# migration_upgrade_bun_old_no_top_level_catalog + +Bun before 1.2.21 reads only `workspaces.catalog`, so a top-level `catalog` would fail to resolve. + +## `vp migrate --no-interactive` + +an empty-workspaces bun 1.2.20 project without a catalog upgrades + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node bun +• Dependencies: + vite-plus 0.1.20 → + vite → +• Package manager settings configured +``` + +## `vpt print-file package.json` + +toolchain specs stay concrete and no top-level catalog is written + +``` +{ + "name": "migration-upgrade-bun-old-no-top-level-catalog", + "workspaces": [], + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vp migrate --no-interactive` + +a second run converges to the already-migrated fast exit + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` From 91640ae94ea2dc740811e9fa08d7a6c6e70e8e58 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 14:33:16 +0900 Subject: [PATCH 4/7] fix(migrate): keep bun 1.2.20 fresh migrations off a top-level catalog The fresh monorepo path always wrote a top-level catalog for Bun, which Bun before 1.2.21 cannot resolve. Share bunSupportsCatalog between the fresh and upgrade paths, keep root overrides concrete when catalogs are unsupported, and start the Bun 1.2.20 snapshot without vite-plus installed. --- .../package.json | 14 +++++++ .../snapshots.toml | 4 +- ...migration_bun_old_no_top_level_catalog.md} | 22 +++++------ .../package.json | 19 --------- .../src/migration/__tests__/migrator.spec.ts | 23 +++++++++++ .../cli/src/migration/migrator/catalog.ts | 39 ++++++++++++++----- .../src/migration/migrator/orchestrators.ts | 15 ++++--- .../migration/migrator/vite-plus-bootstrap.ts | 29 ++------------ 8 files changed, 93 insertions(+), 72 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/package.json rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/{migration_upgrade_bun_old_no_top_level_catalog => migration_bun_old_no_top_level_catalog}/snapshots.toml (77%) rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/{migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md => migration_bun_old_no_top_level_catalog/snapshots/migration_bun_old_no_top_level_catalog.md} (72%) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/package.json new file mode 100644 index 0000000000..1c7eb23643 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-bun-old-no-top-level-catalog", + "workspaces": [], + "devDependencies": { + "vite": "^7.0.0" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "1.2.20", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots.toml similarity index 77% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots.toml index f5b5ee9375..74b868c39e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots.toml @@ -1,9 +1,9 @@ [[case]] -name = "migration_upgrade_bun_old_no_top_level_catalog" +name = "migration_bun_old_no_top_level_catalog" vp = "global" comment = "Bun before 1.2.21 reads only `workspaces.catalog`, so a top-level `catalog` would fail to resolve." steps = [ - { argv = ["vp", "migrate", "--no-interactive"], comment = "an empty-workspaces bun 1.2.20 project without a catalog upgrades", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "an empty-workspaces bun 1.2.20 project without Vite+ migrates", continue-on-failure = true }, { argv = ["vpt", "print-file", "package.json"], comment = "toolchain specs stay concrete and no top-level catalog is written", continue-on-failure = true }, { argv = ["vp", "migrate", "--no-interactive"], comment = "a second run converges to the already-migrated fast exit", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots/migration_bun_old_no_top_level_catalog.md similarity index 72% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots/migration_bun_old_no_top_level_catalog.md index 84158929e9..80c62fabd4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/snapshots/migration_upgrade_bun_old_no_top_level_catalog.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots/migration_bun_old_no_top_level_catalog.md @@ -1,20 +1,17 @@ -# migration_upgrade_bun_old_no_top_level_catalog +# migration_bun_old_no_top_level_catalog Bun before 1.2.21 reads only `workspaces.catalog`, so a top-level `catalog` would fail to resolve. ## `vp migrate --no-interactive` -an empty-workspaces bun 1.2.20 project without a catalog upgrades +an empty-workspaces bun 1.2.20 project without Vite+ migrates ``` VITE+ - The Unified Toolchain for the Web -◇ Updated . to Vite+ +◇ Migrated . to Vite+ • Node bun -• Dependencies: - vite-plus 0.1.20 → - vite → -• Package manager settings configured +• 2 config updates applied ``` ## `vpt print-file package.json` @@ -23,21 +20,24 @@ toolchain specs stay concrete and no top-level catalog is written ``` { - "name": "migration-upgrade-bun-old-no-top-level-catalog", + "name": "migration-bun-old-no-top-level-catalog", "workspaces": [], "devDependencies": { "vite": "npm:@voidzero-dev/vite-plus-core@", "vite-plus": "" }, - "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@" - }, "devEngines": { "packageManager": { "name": "bun", "version": "", "onFail": "download" } + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "scripts": { + "prepare": "vp config" } } ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json deleted file mode 100644 index db304bd6b2..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_upgrade_bun_old_no_top_level_catalog/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "migration-upgrade-bun-old-no-top-level-catalog", - "workspaces": [], - "devDependencies": { - "vite-plus": "^0.1.20", - "vitest": "4.1.9" - }, - "overrides": { - "vite": "npm:@voidzero-dev/vite-plus-core@0.1.20", - "vitest": "4.1.9" - }, - "devEngines": { - "packageManager": { - "name": "bun", - "version": "1.2.20", - "onFail": "download" - } - } -} diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c1728debe7..335fbbe8d2 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -8021,6 +8021,29 @@ describe('rewriteMonorepo bun catalog', () => { expect(overrides.vite).toBe('catalog:'); }); + it('keeps concrete specs and writes no top-level catalog on bun < 1.2.21', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-monorepo', + workspaces: [], + devDependencies: { vite: '^7.0.0' }, + packageManager: 'bun@1.2.20', + }), + ); + rewriteMonorepo(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.2.20'), true); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + overrides: Record; + catalog?: Record; + }; + expect(pkg.catalog).toBeUndefined(); + expect(pkg.devDependencies.vite).toBe(VITE_PLUS_OVERRIDE_PACKAGES.vite); + expect(pkg.devDependencies['vite-plus']).toBe('latest'); + expect(pkg.overrides.vite).toBe(VITE_PLUS_OVERRIDE_PACKAGES.vite); + }); + it('writes catalog to workspaces.catalog when workspaces is an object with existing catalog', () => { 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 395a6e6bdc..de19e53c87 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -126,10 +126,29 @@ export function yarnSupportsCatalog(version: string): boolean { const BUN_TOP_LEVEL_CATALOG_MIN_VERSION = '1.2.21'; -// Bun before 1.2.21 reads only `workspaces.catalog`, so a top-level `catalog` -// fails with "catalog: failed to resolve" there (verified on 1.2.14 to 1.2.20). -export function bunSupportsTopLevelCatalog(version: string): boolean { - return versionAtLeast(version, BUN_TOP_LEVEL_CATALOG_MIN_VERSION, ['latest', 'canary']); +// Bun resolves `catalog:` references ONLY inside a workspace: a root +// package.json with a `workspaces` field. An empty one (`[]` or +// `{ packages: [] }`) is enough (#3375). Bun before 1.2.21 also ignores a +// top-level `catalog` or `catalogs` (verified on 1.2.14 to 1.2.20), so it +// needs the nested location that `rewriteBunCatalog` keeps in place. Otherwise +// `catalog:` edges make `bun install` abort with "vite@catalog: failed to +// resolve", so the toolchain edges must stay concrete. +export function bunSupportsCatalog( + pkg: { workspaces?: NpmWorkspaces; catalog?: Record }, + version: string, +): boolean { + const { workspaces } = pkg; + if (workspaces == null) { + return false; + } + // Mirrors `useWorkspacesCatalog` in rewriteBunCatalog. + const writesNestedCatalog = + !Array.isArray(workspaces) && + (workspaces.catalog != null || (pkg.catalog == null && workspaces.catalogs != null)); + return ( + writesNestedCatalog || + versionAtLeast(version, BUN_TOP_LEVEL_CATALOG_MIN_VERSION, ['latest', 'canary']) + ); } // Whether a `catalog:` reference resolves for this package manager and version: @@ -1210,18 +1229,20 @@ export function rewriteRootWorkspacePackageJson( }; } else if (packageManager === PackageManager.bun) { // bun overrides are handled in rewriteBunCatalog() with catalog: references + // when the workspace supports a catalog. Otherwise they stay concrete. // The browser override has no catalog entry and was pruned above with // stale bundled-package pins. Restore its current concrete version. - if (managed['@vitest/browser']) { + if (!supportCatalog) { + pkg.overrides = { ...pkg.overrides, ...managed }; + } else if (managed['@vitest/browser']) { pkg.overrides = { ...pkg.overrides, '@vitest/browser': managed['@vitest/browser'] }; } // Bun walks transitive peer-deps before resolving overrides; vitest 4.1.9 // declares peer `vite ^6 || ^7 || ^8` and aborts unless `vite` is a direct // dep at the workspace root. Mirror the override as a devDep; the override - // configured in rewriteBunCatalog still redirects it to vite-plus-core. A - // bun workspace root always manages a catalog, so the direct edge resolves - // to a `catalog:` reference. See https://github.com/oven-sh/bun/issues/8406. - setDirectViteEdge(pkg, true, catalogDependencyResolver); + // configured in rewriteBunCatalog still redirects it to vite-plus-core. + // See https://github.com/oven-sh/bun/issues/8406. + setDirectViteEdge(pkg, supportCatalog, catalogDependencyResolver); } else if (packageManager === PackageManager.pnpm) { const overrideKeys = Object.keys(managedOverridePackages(workspaceUsesVitest)); const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings(pnpmVersion ?? ''); diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index dc4a2c5550..3439df9ffe 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -3,10 +3,11 @@ import path from 'node:path'; import { PackageManager, type WorkspaceInfo, type WorkspacePackage } from '../../types/index.ts'; import { VITE_PLUS_NAME, VITE_PLUS_VERSION, isForceOverrideMode } from '../../utils/constants.ts'; -import { editJsonFile } from '../../utils/json.ts'; +import { editJsonFile, readJsonFile } from '../../utils/json.ts'; import { applyBuildAllowanceToPackageJsonPnpm, applyYarnWorkspaceHoistingFix, + bunSupportsCatalog, cleanupDeprecatedTsconfigOptions, collectInjectedProviderNames, collectOxlintDependencyNames, @@ -395,12 +396,16 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packages, ); - // Catalog support: pnpm >= 9.5.0, Yarn >= 4.10.0, bun in a workspace (always - // true here — this is the monorepo path). An older PM falls back to concrete. + // Catalog support: pnpm >= 9.5.0, Yarn >= 4.10.0, bun per `bunSupportsCatalog`. + // An older PM falls back to concrete. const supportCatalog = supportsCatalog( workspaceInfo.packageManager, workspaceInfo.downloadPackageManager.version, - true, + workspaceInfo.packageManager === PackageManager.bun && + bunSupportsCatalog( + readJsonFile(path.join(workspaceInfo.rootDir, 'package.json')), + workspaceInfo.downloadPackageManager.version, + ), ); const workspaceContext = { rootDir: workspaceInfo.rootDir, @@ -419,7 +424,7 @@ export function rewriteMonorepo( providerCatalogAdditions, supportCatalog, ); - } else if (workspaceInfo.packageManager === PackageManager.bun) { + } else if (workspaceInfo.packageManager === PackageManager.bun && supportCatalog) { rewriteBunCatalog( workspaceInfo.rootDir, workspaceUsesVitest, diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index 567127c6bf..f14e838896 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -32,7 +32,7 @@ import { pnpmPackageJsonSettingsPending, pnpmSupportsWorkspaceSettings, supportsCatalog, - bunSupportsTopLevelCatalog, + bunSupportsCatalog, pnpmWorkspaceMinimumReleaseAgeExemptionsPending, projectUsesVitestDirectly, pruneLegacyWrapperAliases, @@ -68,26 +68,6 @@ import { type PnpmPackageJsonSettings, } from './shared.ts'; -// Bun resolves `catalog:` references (and a top-level `catalog` field) ONLY -// inside a workspace: a root package.json with a `workspaces` field. An empty -// one (`[]` or `{ packages: [] }`) is enough (#3375). A standalone bun project -// has nowhere to resolve a catalog from, so converting its edges to `catalog:` -// makes `bun install` abort with "vite@catalog: failed to resolve". Bun before -// 1.2.21 also ignores a top-level `catalog`, so it needs an existing -// `workspaces.catalog`, which `rewriteBunCatalog` keeps in place. -// The upgrade path must keep standalone bun on concrete specs, mirroring the -// fresh path: `supportCatalog` is false for standalone bun (orchestrators.ts) -// and `rewriteBunCatalog` runs only on monorepo roots (orchestrators.ts). -function bunSupportsCatalog(workspaces: NpmWorkspaces | undefined, version: string): boolean { - if (workspaces == null) { - return false; - } - return ( - (!Array.isArray(workspaces) && workspaces.catalog != null) || - bunSupportsTopLevelCatalog(version) - ); -} - export type BootstrapPackageJson = { overrides?: Record; resolutions?: Record; @@ -680,7 +660,7 @@ export function detectVitePlusBootstrapPending( packageManager, resolvedPackageManagerVersion, packageManager === PackageManager.bun && - bunSupportsCatalog(pkg.workspaces, resolvedPackageManagerVersion), + bunSupportsCatalog(pkg, resolvedPackageManagerVersion), ); const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const canonicalVitePlusSpec = supportCatalog @@ -989,10 +969,7 @@ export function ensureVitePlusBootstrap( // with "vite@catalog: failed to resolve". const isBunWorkspace = workspaceInfo.packageManager === PackageManager.bun && - bunSupportsCatalog( - (readJsonFile(packageJsonPath) as { workspaces?: NpmWorkspaces }).workspaces, - workspaceInfo.downloadPackageManager.version, - ); + bunSupportsCatalog(readJsonFile(packageJsonPath), workspaceInfo.downloadPackageManager.version); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && supportsCatalog( From 26eadcb27c1df0dc5a6fc4c62e33474401455506 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 15:19:33 +0900 Subject: [PATCH 5/7] fix(migrate): check the bun catalog vite-plus value before the fast exit A catalog: vite-plus spec always looks current, so a stale catalog value let detectVitePlusBootstrapPending report a Bun workspace as migrated and skip the upgrade. Check the catalog value like the pnpm and Yarn paths do. --- .../src/migration/__tests__/migrator.spec.ts | 29 +++++++++++++++++++ .../migration/migrator/vite-plus-bootstrap.ts | 18 ++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 335fbbe8d2..a71d7214d4 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2137,6 +2137,35 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.overrides).toEqual({ vite: 'catalog:' }); }); + it('reports a stale bun catalog vite-plus value as pending', () => { + // The `catalog:` spec itself looks current, so only the catalog value shows + // that vite-plus still needs the upgrade. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-stale-catalog', + devDependencies: { vite: 'catalog:', 'vite-plus': 'catalog:' }, + overrides: { vite: 'catalog:' }, + workspaces: { + packages: [], + catalog: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, 'vite-plus': '0.1.20' }, + }, + devEngines: { + packageManager: { name: 'bun', version: '1.4.4', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(true); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.4.4')); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + workspaces: { catalog: Record }; + }; + expect(pkg.workspaces.catalog['vite-plus']).not.toBe('0.1.20'); + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(false); + }); + it('keeps concrete specs on bun < 1.2.21, which ignores a top-level catalog', () => { // Without an existing `workspaces.catalog`, rewriteBunCatalog would write a // top-level `catalog`, which bun 1.2.14 to 1.2.20 cannot resolve. diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index f14e838896..5a4197d669 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -726,12 +726,18 @@ export function detectVitePlusBootstrapPending( // `catalog:` references, so the catalog resolver must NOT be consulted — // otherwise a dangling `catalog:` override resolves through the dead catalog // field and is mistaken for satisfied, masking the pending rewrite. - return !overridesSatisfyVitePlus( - pkg.overrides, - usesVitest, - supportCatalog ? readBunCatalogDependencyResolver(pkg) : undefined, - 'bare', - usesWebdriverio, + const bunCatalogResolver = supportCatalog ? readBunCatalogDependencyResolver(pkg) : undefined; + // A `catalog:` vite-plus spec looks current, so judge its catalog value too. + return ( + (supportCatalog && + workspaceCatalogVitePlusDependencyPending(projectPath, packages, bunCatalogResolver)) || + !overridesSatisfyVitePlus( + pkg.overrides, + usesVitest, + bunCatalogResolver, + 'bare', + usesWebdriverio, + ) ); } if (packageManager === PackageManager.pnpm) { From 0cdabdd639b44f005f889ad03a60234b4f00fd7d Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 16:21:18 +0900 Subject: [PATCH 6/7] fix(migrate): add missing @vitest/* entries to the bun catalog alignVitestEcosystemPackages turns an alignable @vitest/* dependency into a catalog: reference, but the Bun catalog rewrite only updated entries it already had, so bun install failed with "not in catalog". Add the missing entries when the catalog owns vitest, matching the pnpm catalog. --- .../src/migration/__tests__/migrator.spec.ts | 28 +++++++++++++++++++ .../cli/src/migration/migrator/catalog.ts | 8 +++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index a71d7214d4..fff1fbb8c9 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2137,6 +2137,34 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.overrides).toEqual({ vite: 'catalog:' }); }); + it('adds a missing @vitest/* entry to the bun catalog it now references', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-coverage', + devDependencies: { + '@vitest/coverage-v8': '4.1.9', + vite: 'catalog:', + 'vite-plus': 'catalog:', + }, + overrides: { vite: 'catalog:' }, + workspaces: { + packages: [], + catalog: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite, 'vite-plus': '0.1.20' }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.4.4')); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + workspaces: { catalog: Record }; + }; + expect(pkg.devDependencies['@vitest/coverage-v8']).toBe('catalog:'); + expect(pkg.workspaces.catalog['@vitest/coverage-v8']).toBe(VITEST_VERSION); + }); + it('reports a stale bun catalog vite-plus value as pending', () => { // The `catalog:` spec itself looks current, so only the catalog value shows // that vite-plus still needs the upgrade. diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index de19e53c87..b44394cac5 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1048,7 +1048,13 @@ function rewriteCatalogObject( delete catalog[name]; } if (VITEST_IS_MANAGED_OVERRIDE) { - for (const name of Object.keys(catalog)) { + // Like the pnpm catalog, add every declared alignable @vitest/* when the + // catalog owns `vitest`, so its new `catalog:` reference resolves. #2005 + const names = + addMissing && 'vitest' in catalog + ? new Set([...Object.keys(catalog), ...vitestEcosystemPackages]) + : Object.keys(catalog); + for (const name of names) { if (vitestEcosystemPackages.has(name) && isAlignableVitestEcosystemPackage(name)) { catalog[name] = VITEST_VERSION; } From 163939de461c83a6c9d8d4e60bd21ed239598830 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Mon, 9 Nov 2026 16:32:00 +0900 Subject: [PATCH 7/7] fix(migrate): add Vite+ entries to the named bun catalog they reference When a named Bun catalog already owns the toolchain, new references point at it, but rewriteBunCatalog added missing entries only to the default catalog, so vite-plus@catalog: failed to resolve. Add them to that named catalog instead of creating a default catalog beside it, as the pnpm catalog already does, and update the Bun migration rules. --- docs/guide/migrate-rules.md | 4 +- .../bun-catalog-file-protocol.spec.ts | 4 +- .../src/migration/__tests__/migrator.spec.ts | 46 ++++++++++++++++--- .../cli/src/migration/migrator/catalog.ts | 37 +++++++++++---- 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index c37dcf3c13..3101d3f5dd 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -398,7 +398,9 @@ Migration converts the Volta `volta.node` pin to `.node-version`, because Vite+ ### Bun -- Bun catalogs only resolve inside a workspace (a root `package.json` with a non-empty `workspaces`). In a bun workspace, existing top-level or workspace catalog locations and named catalog references are preserved. A standalone (single-package) bun project keeps concrete specs and gets no catalog field, because `bun install` cannot resolve `catalog:` outside a workspace. +- Bun catalogs only resolve inside a workspace, which is a root `package.json` with a `workspaces` field. An empty `workspaces` field also counts. In a bun workspace, existing top-level or workspace catalog locations and named catalog references are preserved. A standalone bun project without `workspaces` keeps concrete specs and gets no catalog field, because `bun install` cannot resolve `catalog:` outside a workspace. +- Bun before 1.2.21 reads only catalogs under `workspaces`. On those versions, migration uses catalogs only when the project already has one under `workspaces`, and keeps concrete specs otherwise. +- When an existing named catalog already owns `vite-plus`, `vite`, or `vitest`, migration adds missing toolchain entries to that catalog instead of creating a default catalog beside it. - The core alias is mirrored as a direct `vite` dependency so Bun sees the peer provider before applying overrides. ## After the Migration 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 93cd6eae33..911be9f99f 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 @@ -114,8 +114,8 @@ describe('rewriteMonorepo bun catalog with file: protocol', () => { overrides: Record; devDependencies: Record; }; - expect(pkg.workspaces.catalog.vite).toBeUndefined(); - expect(pkg.workspaces.catalog.vitest).toBeUndefined(); + 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(); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index fff1fbb8c9..8b19a86b3a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -2165,6 +2165,34 @@ describe('ensureVitePlusBootstrap', () => { expect(pkg.workspaces.catalog['@vitest/coverage-v8']).toBe(VITEST_VERSION); }); + it('adds vite-plus to the named bun catalog its new reference points at', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'bun-named-catalog', + devDependencies: { vite: 'catalog:build', 'vite-plus': '^0.1.20' }, + overrides: { vite: 'catalog:build' }, + workspaces: { + packages: [], + catalogs: { build: { vite: VITE_PLUS_OVERRIDE_PACKAGES.vite } }, + }, + }), + ); + + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.4.4')); + + const pkg = readJson(path.join(tmpDir, 'package.json')) as { + devDependencies: Record; + workspaces: { + catalog?: Record; + catalogs: Record>; + }; + }; + expect(pkg.devDependencies['vite-plus']).toBe('catalog:build'); + expect(pkg.workspaces.catalogs.build['vite-plus']).toBe('latest'); + expect(pkg.workspaces.catalog).toBeUndefined(); + }); + it('reports a stale bun catalog vite-plus value as pending', () => { // The `catalog:` spec itself looks current, so only the catalog value shows // that vite-plus still needs the upgrade. @@ -8215,8 +8243,11 @@ describe('rewriteMonorepo bun catalog', () => { devDependencies: Record; peerDependencies: Record; }; - expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + // Like pnpm, the managed `build` catalog takes the Vite+ entries, so no + // default catalog is created beside it. + expect(pkg.catalog).toBeUndefined(); expect(pkg.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.catalogs.build['vite-plus']).toBe('latest'); expect(pkg.catalogs.build.react).toBe('^19.0.0'); expect(pkg.catalogs.build.tsdown).toBeUndefined(); // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, @@ -8226,11 +8257,12 @@ describe('rewriteMonorepo bun catalog', () => { expect(pkg.overrides.vite).toBe('catalog:build'); expect(pkg.overrides.vitest).toBeUndefined(); expect(pkg.devDependencies.vite).toBe('catalog:build'); + expect(pkg.devDependencies['vite-plus']).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', () => { + it('rewrites workspaces named catalogs and adds Vite+ entries to the managed one', () => { fs.writeFileSync( path.join(tmpDir, 'package.json'), JSON.stringify({ @@ -8258,15 +8290,15 @@ describe('rewriteMonorepo bun catalog', () => { 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.catalog).toBeUndefined(); expect(pkg.workspaces.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.workspaces.catalogs.build['vite-plus']).toBe('latest'); expect(pkg.workspaces.catalogs.build.oxlint).toBeUndefined(); // Common case (no @vitest/* dep, no vitest source): `vitest` is not managed, // so the pre-existing named-catalog `vitest` entry is REMOVED. expect(pkg.workspaces.catalogs.test.vitest).toBeUndefined(); expect(pkg.workspaces.catalogs.test.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); - expect(pkg.overrides.vite).toBe('catalog:'); + expect(pkg.overrides.vite).toBe('catalog:build'); }); it('keeps an existing top-level default catalog when workspaces named catalogs exist', () => { @@ -8295,10 +8327,10 @@ describe('rewriteMonorepo bun catalog', () => { catalogs: Record>; }; }; - expect(pkg.catalog.react).toBe('^19.0.0'); - expect(pkg.catalog.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.catalog).toEqual({ react: '^19.0.0' }); expect(pkg.workspaces.catalog).toBeUndefined(); expect(pkg.workspaces.catalogs.build.vite).toBe('npm:@voidzero-dev/vite-plus-core@latest'); + expect(pkg.workspaces.catalogs.build['vite-plus']).toBe('latest'); }); }); diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index b44394cac5..6a94b4dd9a 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -1066,9 +1066,10 @@ function rewriteCatalogsObject( catalogs: Record>, usesVitest: boolean, vitestEcosystemPackages: ReadonlySet, + targetCatalog?: Record, ): void { for (const catalog of Object.values(catalogs)) { - rewriteCatalogObject(catalog, false, usesVitest, vitestEcosystemPackages); + rewriteCatalogObject(catalog, catalog === targetCatalog, usesVitest, vitestEcosystemPackages); } } @@ -1102,14 +1103,23 @@ export function rewriteBunCatalog( pkg.workspaces && !Array.isArray(pkg.workspaces) ? pkg.workspaces : undefined; const useWorkspacesCatalog = workspacesObj?.catalog != null || (pkg.catalog == null && workspacesObj?.catalogs != null); - const catalog: Record = { - ...(useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog), - }; - - rewriteCatalogObject(catalog, true, usesVitest, vitestEcosystemPackages); + // Like the pnpm catalog, add missing entries to the catalog the new + // `catalog:` references point at. A named one such as `catalogs.build` + // takes them instead of a newly created default catalog. + const { preferredCatalogSpec } = readBunCatalogDependencyResolver(pkg); + const preferredName = preferredCatalogSpec.slice('catalog:'.length); + const namedTarget = preferredName + ? (workspacesObj?.catalogs?.[preferredName] ?? pkg.catalogs?.[preferredName]) + : undefined; + const defaultCatalog = useWorkspacesCatalog ? workspacesObj?.catalog : pkg.catalog; + const catalog: Record = { ...defaultCatalog }; + + rewriteCatalogObject(catalog, !namedTarget, usesVitest, vitestEcosystemPackages); pruneLegacyWrapperAliases(catalog); - if (useWorkspacesCatalog) { + if (namedTarget && !defaultCatalog) { + // No default catalog to update, and none is needed. + } else if (useWorkspacesCatalog) { workspacesObj.catalog = catalog; if (pkg.catalog) { rewriteCatalogObject(pkg.catalog, false, usesVitest, vitestEcosystemPackages); @@ -1123,13 +1133,18 @@ export function rewriteBunCatalog( } } if (workspacesObj?.catalogs) { - rewriteCatalogsObject(workspacesObj.catalogs, usesVitest, vitestEcosystemPackages); + rewriteCatalogsObject( + workspacesObj.catalogs, + usesVitest, + vitestEcosystemPackages, + namedTarget, + ); for (const named of Object.values(workspacesObj.catalogs)) { pruneLegacyWrapperAliases(named); } } if (pkg.catalogs) { - rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages); + rewriteCatalogsObject(pkg.catalogs, usesVitest, vitestEcosystemPackages, namedTarget); for (const named of Object.values(pkg.catalogs)) { pruneLegacyWrapperAliases(named); } @@ -1153,7 +1168,9 @@ export function rewriteBunCatalog( continue; } overrides[key] = - key === '@vitest/browser' ? value : getCatalogDependencySpec(current, value, true); + key === '@vitest/browser' + ? value + : getCatalogDependencySpec(current, value, true, { preferredCatalogSpec }); } pkg.overrides = overrides;