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_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 new file mode 100644 index 0000000000..74b868c39e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_bun_old_no_top_level_catalog/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +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 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_bun_old_no_top_level_catalog/snapshots/migration_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 new file mode 100644 index 0000000000..80c62fabd4 --- /dev/null +++ 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 @@ -0,0 +1,53 @@ +# 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 Vite+ migrates + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Migrated . to Vite+ +• Node bun +• 2 config updates applied +``` + +## `vpt print-file package.json` + +toolchain specs stay concrete and no top-level catalog is written + +``` +{ + "name": "migration-bun-old-no-top-level-catalog", + "workspaces": [], + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@", + "vite-plus": "" + }, + "devEngines": { + "packageManager": { + "name": "bun", + "version": "", + "onFail": "download" + } + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + }, + "scripts": { + "prepare": "vp config" + } +} +``` + +## `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/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/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 c1bcb66131..8b19a86b3a 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,151 @@ 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. A + // `workspaces.catalog` resolves on every catalog-capable bun, including 1.2.14. + 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.2.14', onFail: 'download' }, + }, + }), + ); + + expect(detectVitePlusBootstrapPending(tmpDir, PackageManager.bun)).toBe(false); + ensureVitePlusBootstrap(makeWorkspaceInfo(tmpDir, PackageManager.bun, '1.2.14')); + + 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('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('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. + 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. + 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`. @@ -7961,6 +8106,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'), @@ -8075,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, @@ -8086,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({ @@ -8118,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', () => { @@ -8155,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 cd6b13e998..6a94b4dd9a 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -124,6 +124,33 @@ 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 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: // 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. @@ -1021,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; } @@ -1033,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); } } @@ -1069,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); @@ -1090,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); } @@ -1120,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; @@ -1202,18 +1252,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 5a4105b168..5a4197d669 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, + bunSupportsCatalog, pnpmWorkspaceMinimumReleaseAgeExemptionsPending, projectUsesVitestDirectly, pruneLegacyWrapperAliases, @@ -67,24 +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 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". -// 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; -} - export type BootstrapPackageJson = { overrides?: Record; resolutions?: Record; @@ -676,7 +659,8 @@ export function detectVitePlusBootstrapPending( supportsCatalog( packageManager, resolvedPackageManagerVersion, - packageManager === PackageManager.bun && bunWorkspaceDeclaresPackages(pkg.workspaces), + packageManager === PackageManager.bun && + bunSupportsCatalog(pkg, resolvedPackageManagerVersion), ); const catalogDependencyResolver = createCatalogDependencyResolver(projectPath, packageManager); const canonicalVitePlusSpec = supportCatalog @@ -742,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) { @@ -979,15 +969,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( - (readJsonFile(packageJsonPath) as { workspaces?: NpmWorkspaces }).workspaces, - ); + bunSupportsCatalog(readJsonFile(packageJsonPath), workspaceInfo.downloadPackageManager.version); const supportCatalog = !VITE_PLUS_VERSION.startsWith('file:') && supportsCatalog(